From f54c10f6a7acf01e4a474ac069fd183594d44953 Mon Sep 17 00:00:00 2001 From: LBX154 <145820328+lbx154@users.noreply.github.com> Date: Wed, 23 Sep 2026 07:18:38 -0700 Subject: [PATCH] refactor: remove web API facade indirection and duplicate query logic --- argus/trial/web_runtime.py | 4 +- argus/webapi/__init__.py | 5 +- argus/webapi/_server_module.py | 21 -- argus/webapi/daemon_lifecycle.py | 73 ++-- argus/webapi/daemon_upgrade.py | 68 ++-- argus/webapi/mission_items.py | 6 +- argus/webapi/project_crud.py | 6 +- argus/webapi/routes/__init__.py | 12 +- argus/webapi/routes/artifacts.py | 15 +- argus/webapi/routes/context.py | 125 ++----- argus/webapi/routes/counterexamples.py | 7 +- argus/webapi/routes/daemon.py | 110 +++--- argus/webapi/routes/manager.py | 18 +- argus/webapi/routes/meta.py | 50 ++- argus/webapi/routes/projects.py | 38 +- argus/webapi/routes/workitems.py | 8 +- argus/webapi/routes/workspace_v2.py | 2 +- argus/webapi/server.py | 270 ++------------ docs/runtime-maintainability.md | 7 + tests/core/test_usage.py | 2 +- tests/test_architecture_invariants.py | 2 +- tests/trial/test_journey_journal.py | 3 +- tests/webapi/test_abort_control.py | 2 +- tests/webapi/test_cancelled_manager_reply.py | 3 +- tests/webapi/test_commands_m1.py | 345 +++++++++--------- tests/webapi/test_control_responsiveness.py | 17 +- tests/webapi/test_daemon_services.py | 20 +- tests/webapi/test_dispatch_receipt_races.py | 11 +- tests/webapi/test_dispatch_receipt_truth.py | 5 +- tests/webapi/test_domain_intake_flow.py | 5 +- tests/webapi/test_explicit_provider_resume.py | 4 +- tests/webapi/test_live_daemon_is_visible.py | 6 +- .../test_maintenance_decision_cleanup.py | 3 +- tests/webapi/test_message.py | 91 ++--- .../test_message_cancellation_lifecycle.py | 2 +- tests/webapi/test_message_stop_delivery.py | 11 +- .../test_pending_answer_cancellation.py | 3 +- .../test_project_index_cache_freshness.py | 10 +- tests/webapi/test_query_concurrency.py | 16 +- tests/webapi/test_server_m0.py | 57 +-- tests/webapi/test_source_update.py | 6 +- tests/webapi/test_wave1.py | 47 +-- tests/webapi/test_workspace_v2.py | 4 +- 43 files changed, 621 insertions(+), 899 deletions(-) delete mode 100644 argus/webapi/_server_module.py diff --git a/argus/trial/web_runtime.py b/argus/trial/web_runtime.py index 7a58561db..bdd740c51 100644 --- a/argus/trial/web_runtime.py +++ b/argus/trial/web_runtime.py @@ -58,7 +58,9 @@ def main() -> None: import uvicorn from ..core.paths import global_root - from ..webapi.server import create_app, create_daemon, list_projects + from ..webapi.daemon_lifecycle import create_daemon + from ..webapi.project_state import list_projects + from ..webapi.server import create_app os.umask(0o077) config = json.loads(Path("/bootstrap/runtime.json").read_text()) diff --git a/argus/webapi/__init__.py b/argus/webapi/__init__.py index 91d3fb00b..f4cae234b 100644 --- a/argus/webapi/__init__.py +++ b/argus/webapi/__init__.py @@ -23,7 +23,10 @@ def __getattr__(name: str): # lazy re-export so importing the package never needs fastapi - if name in __all__: + if name in {"build_snapshot", "project_life_dir"}: + from . import project_state + return getattr(project_state, name) + if name in {"create_app", "serve"}: from . import server return getattr(server, name) raise AttributeError(name) diff --git a/argus/webapi/_server_module.py b/argus/webapi/_server_module.py deleted file mode 100644 index 524e85894..000000000 --- a/argus/webapi/_server_module.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Late-bound access to the ``server`` module for its split-out siblings. - -``server.py`` was split by API domain, and the pieces still call back into it -for things like ``read_daemon_status`` or ``stop_daemon``. Importing it at -module scope would both close the import cycle and bind the names at import -time, so a test that monkeypatches ``server.`` would not be seen. Four -modules each carried their own copy of this three-line resolver. -""" -from __future__ import annotations - -from typing import Any - - -def server_module() -> Any: - """Resolve ``webapi.server`` at call time so monkeypatching still works.""" - from . import server - - return server - - -__all__ = ["server_module"] diff --git a/argus/webapi/daemon_lifecycle.py b/argus/webapi/daemon_lifecycle.py index d9c4d960b..cdef388a8 100644 --- a/argus/webapi/daemon_lifecycle.py +++ b/argus/webapi/daemon_lifecycle.py @@ -1,10 +1,4 @@ -"""Daemon lifecycle and upgrade orchestration for the webapi server. - -Extracted from ``server.py`` as part of a behavior-preserving decomposition. -Public/private names remain re-exported from ``server`` for backward -compatibility (tests and ``webapi/routes/*`` reach them via ``server.*`` / -``server_mod.*``). -""" +"""Daemon lifecycle operations over shared session, state, and process controls.""" from __future__ import annotations @@ -30,17 +24,20 @@ update_session_meta, write_session_meta, ) +from ..daemon import life_worker as daemon_worker from ..daemon.life_worker import ( LifeWorkerConfig, _acquire_daemon_spawn_lock, + _active_daemon_count, + _active_workspace_owner, _launcher_failure_message, + _max_active_daemons, _release_daemon_spawn_lock, _workspace_start_error, ) from ..life.memory import LifeMemory from ..life.role_activity import role_activity from . import project_state -from ._server_module import server_module as _srv log = logging.getLogger(__name__) @@ -71,7 +68,7 @@ def _worker_config_from_env(life_dir: Path, global_root: Path) -> LifeWorkerConf ) meta = read_session_meta(global_root, life_dir.name) if not session_workdir_is_bound(meta): - prior = _srv().read_daemon_status(life_dir).project_workdir + prior = daemon_worker.read_daemon_status(life_dir).project_workdir project_workdir = migrate_legacy_session_workdir( global_root, life_dir.name, @@ -172,7 +169,7 @@ def list_running_daemons( roles = [] active_role = next((role for role in roles if role.get("active")), None) try: - continuous = _srv().read_continuous_state(life_dir) + continuous = daemon_worker.read_continuous_state(life_dir) except Exception: # noqa: BLE001 continuous = None rows.append({ @@ -197,7 +194,7 @@ def _admission_required( active_count: int, resume_continuous: bool, ) -> dict[str, Any]: - running = _srv().list_running_daemons(global_root=root, exclude_sid=sid) + running = list_running_daemons(global_root=root, exclude_sid=sid) admission = { "rc": 2, "already_alive": False, @@ -244,7 +241,7 @@ def start_project_daemon( if life_dir is None: return None root = _global_root(global_root) - st = _srv().read_daemon_status(life_dir) + st = daemon_worker.read_daemon_status(life_dir) if st.alive: _clear_daemon_admission(life_dir) return {"rc": 0, "already_alive": True, "daemon": _daemon_dict(st)} @@ -255,7 +252,7 @@ def start_project_daemon( "rc": 3, "already_alive": False, "error": f"daemon workdir is unavailable: {exc}", - "daemon": _daemon_dict(_srv().read_daemon_status(life_dir)), + "daemon": _daemon_dict(daemon_worker.read_daemon_status(life_dir)), } # Web project workers are finite unless they adopt a persisted campaign # that is explicitly both enabled and open-ended. Otherwise the dataclass's @@ -263,27 +260,27 @@ def start_project_daemon( # including one started with the cockpit's Resume button. config.continuous_open_ended = False if resume_continuous: - continuous = _srv().read_continuous_state(life_dir) + continuous = daemon_worker.read_continuous_state(life_dir) if ( not continuous.enabled and continuous.objective.strip() and continuous.done_reason.strip().lower().startswith("operator ") ): - _srv().write_continuous_config( + daemon_worker.write_continuous_config( life_dir, enabled=True, objective=continuous.objective, ) - continuous = _srv().read_continuous_state(life_dir) + continuous = daemon_worker.read_continuous_state(life_dir) if continuous.enabled: config.continuous_objective = continuous.objective config.resume_continuous = True config.continuous_open_ended = continuous.open_ended - daemon_limit = _srv()._max_active_daemons(config) - active_count = _srv()._active_daemon_count(config) + daemon_limit = _max_active_daemons(config) + active_count = _active_daemon_count(config) if daemon_limit > 0 and active_count >= daemon_limit: if reclaim_idle: - running = _srv().list_running_daemons(global_root=root, exclude_sid=sid) + running = list_running_daemons(global_root=root, exclude_sid=sid) idle = [ row for row in running if int(row.get("unfinished_tasks") or 0) == 0 @@ -295,7 +292,7 @@ def start_project_daemon( idle, key=lambda row: float(row.get("last_active") or 0.0), ) - replaced = _srv().replace_project_daemon( + replaced = replace_project_daemon( sid, str(victim.get("id") or ""), global_root=root, @@ -312,18 +309,18 @@ def start_project_daemon( active_count=active_count, resume_continuous=resume_continuous, ), - "daemon": _daemon_dict(_srv().read_daemon_status(life_dir)), + "daemon": _daemon_dict(daemon_worker.read_daemon_status(life_dir)), } startup_diagnostic = "" startup_recovery_diagnostic = "" try: - rc = _srv().spawn_detached_daemon(config, quiet=True) + rc = daemon_worker.spawn_detached_daemon_clean(config, quiet=True) startup_diagnostic = config.last_spawn_error.strip() if _retryable_windows_spawn_failure(rc, startup_diagnostic): # The first launcher can finish just as its runtime publishes # status. Never create a second worker if that happened; otherwise # retry one known-transient Win32 sharing/lock failure exactly once. - after_first = _srv().read_daemon_status(life_dir) + after_first = daemon_worker.read_daemon_status(life_dir) if after_first.alive: startup_recovery_diagnostic = startup_diagnostic startup_diagnostic = "" @@ -334,7 +331,7 @@ def start_project_daemon( sid, startup_diagnostic, ) - rc = _srv().spawn_detached_daemon(config, quiet=True) + rc = daemon_worker.spawn_detached_daemon_clean(config, quiet=True) second_diagnostic = config.last_spawn_error.strip() if rc == 0: startup_recovery_diagnostic = startup_diagnostic @@ -351,12 +348,12 @@ def start_project_daemon( "Check the startup diagnostic and try again." ), "startup_diagnostic": f"{type(exc).__name__}: {exc}", - "daemon": _daemon_dict(_srv().read_daemon_status(life_dir)), + "daemon": _daemon_dict(daemon_worker.read_daemon_status(life_dir)), } result = { "rc": rc, "already_alive": False, - "daemon": _daemon_dict(_srv().read_daemon_status(life_dir)), + "daemon": _daemon_dict(daemon_worker.read_daemon_status(life_dir)), } if startup_diagnostic: result["startup_diagnostic"] = startup_diagnostic @@ -385,7 +382,7 @@ def start_project_daemon( ) return result if rc != 0: - active_count = _srv()._active_daemon_count(config) + active_count = _active_daemon_count(config) if daemon_limit > 0 and active_count >= daemon_limit: return { **_admission_required( @@ -395,7 +392,7 @@ def start_project_daemon( active_count=active_count, resume_continuous=resume_continuous, ), - "daemon": _daemon_dict(_srv().read_daemon_status(life_dir)), + "daemon": _daemon_dict(daemon_worker.read_daemon_status(life_dir)), } result["error"] = ( "The background worker could not start. " @@ -482,22 +479,22 @@ def replace_project_daemon( return {"rc": 2, "error": "target and replacement victim are the same session"} with _DAEMON_REPLACEMENT_LOCK: - victim_status = _srv().read_daemon_status(victim_dir) + victim_status = daemon_worker.read_daemon_status(victim_dir) if not victim_status.alive: return { "rc": 2, "error": f"session {victim_sid} is no longer running; refresh the list", } - stop_rc = _srv().stop_daemon(victim_dir, timeout=2.0, force=True) + stop_rc = daemon_worker.stop_daemon(victim_dir, timeout=2.0, force=True) if stop_rc not in {0, 1}: return { "rc": 2, "error": f"could not park {victim_sid} (stop rc={stop_rc})", } deadline = time.monotonic() + 5.0 - while _srv().read_daemon_status(victim_dir).alive and time.monotonic() < deadline: + while daemon_worker.read_daemon_status(victim_dir).alive and time.monotonic() < deadline: time.sleep(0.05) - if _srv().read_daemon_status(victim_dir).alive: + if daemon_worker.read_daemon_status(victim_dir).alive: return { "rc": 2, "error": f"session {victim_sid} did not release its daemon slot", @@ -508,7 +505,7 @@ def replace_project_daemon( target_sid=sid, previous_pid=victim_status.pid, ) - started = _srv().start_project_daemon( + started = start_project_daemon( sid, global_root=root, resume_continuous=resume_continuous, @@ -605,7 +602,7 @@ def _finish_session(current: SessionMeta) -> None: update_session_meta(root, sid, _finish_session, create=True) # Explicit objective → arm the self-directed campaign + start the daemon # now. The daemon hot-reloads continuous.json. - start_result = _srv().start_project_daemon( + start_result = start_project_daemon( sid, global_root=root, resume_continuous=True, @@ -613,7 +610,7 @@ def _finish_session(current: SessionMeta) -> None: # else: idle session — no continuous, no eager spawn. The Manager (via # /message) writes objectives and lazily spawns the executor when needed. - daemon = _daemon_dict(_srv().read_daemon_status(life_dir)) + daemon = _daemon_dict(daemon_worker.read_daemon_status(life_dir)) rc = int((start_result or {}).get("rc") or 0) response = { "sid": sid, @@ -698,7 +695,7 @@ def set_project_workdir( try: meta = read_session_meta(root, sid) current = resolve_session_workdir(meta, state_dir=life_dir) - status = _srv().read_daemon_status(life_dir) + status = daemon_worker.read_daemon_status(life_dir) if status.alive: if current == target: return {"ok": True, "workdir": str(target), "unchanged": True} @@ -706,7 +703,7 @@ def set_project_workdir( "ok": False, "error": "cannot change workdir while this daemon is running", } - owner = _srv()._active_workspace_owner(config, target_workdir=target) + owner = _active_workspace_owner(config, target_workdir=target) if owner is not None: return { "ok": False, @@ -771,7 +768,7 @@ def stop_project_daemon( from .manager_state import interrupt_manager_turns interrupt_manager_turns(sid, clear_continuous=False) - rc = _srv().stop_daemon( + rc = daemon_worker.stop_daemon( life_dir, timeout=1.0 if force else 10.0, drain=drain, diff --git a/argus/webapi/daemon_upgrade.py b/argus/webapi/daemon_upgrade.py index 8ac882537..cceca5c51 100644 --- a/argus/webapi/daemon_upgrade.py +++ b/argus/webapi/daemon_upgrade.py @@ -1,9 +1,4 @@ -"""Daemon upgrade scheduling and reconciliation for the webapi server. - -Extracted from ``server.py`` / ``daemon_lifecycle.py`` as part of a -behavior-preserving decomposition. Public/private names remain re-exported -from ``server`` for backward compatibility. -""" +"""Schedule and reconcile daemon upgrades using process ownership checks.""" from __future__ import annotations @@ -18,8 +13,11 @@ from typing import Any from ..core import paths as core_paths -from . import project_state -from ._server_module import server_module as _srv +from ..core import runtime_identity as runtime_identity_module +from ..daemon import commands as daemon_commands +from ..daemon import life_worker as daemon_worker +from ..daemon import protocol as daemon_protocol +from . import daemon_lifecycle, project_state log = logging.getLogger(__name__) @@ -40,9 +38,9 @@ def upgrade_project_daemon( life_dir = project_life_dir(sid, global_root=global_root) if life_dir is None: return None - status = _srv().read_daemon_status(life_dir) + status = daemon_worker.read_daemon_status(life_dir) if not status.alive or status.pid is None: - started = _srv().start_project_daemon( + started = daemon_lifecycle.start_project_daemon( sid, global_root=global_root, resume_continuous=True, @@ -50,8 +48,8 @@ def upgrade_project_daemon( return None if started is None else {**started, "upgraded": True} root = _global_root(global_root) - continuous = _srv().read_continuous_state(life_dir) - stop_rc = _srv().stop_daemon( + continuous = daemon_worker.read_continuous_state(life_dir) + stop_rc = daemon_worker.stop_daemon( life_dir, drain=True, drain_timeout=0.0, @@ -64,7 +62,7 @@ def upgrade_project_daemon( "schema_version": 1, "sid": sid, "expected_pid": status.pid, - "source_root": str(_srv().runtime_identity().get("source_root") or ""), + "source_root": str(runtime_identity_module.runtime_identity().get("source_root") or ""), "resume_continuous": bool(continuous.enabled), "objective": str(continuous.objective or ""), "reason": "operator requested current-release restart", @@ -72,7 +70,7 @@ def upgrade_project_daemon( "legacy_drain_timeout": drain_timeout, }, ) - scheduled = _srv().schedule_project_daemon_upgrade( + scheduled = schedule_project_daemon_upgrade( sid, global_root=global_root, ) @@ -86,12 +84,12 @@ def upgrade_project_daemon( "error": "daemon is still draining active work; retry upgrade after it exits", } if continuous.enabled: - _srv().write_continuous_config( + daemon_worker.write_continuous_config( life_dir, enabled=True, objective=continuous.objective, ) - started = _srv().start_project_daemon( + started = daemon_lifecycle.start_project_daemon( sid, global_root=root, resume_continuous=continuous.enabled, @@ -132,7 +130,7 @@ def _write_daemon_upgrade_request( def _upgrade_request_matches_current_source(request: dict[str, Any]) -> bool: requested = str(request.get("source_root") or "").strip() - current = str(_srv().runtime_identity().get("source_root") or "").strip() + current = str(runtime_identity_module.runtime_identity().get("source_root") or "").strip() if not requested or not current: return False try: @@ -171,18 +169,18 @@ def _complete_scheduled_daemon_upgrade( "error": "upgrade request belongs to a different Argus installation", } - status = _srv().read_daemon_status(life_dir) + status = daemon_worker.read_daemon_status(life_dir) if status.alive and status.pid is not None: - compatible, _ = _srv().daemon_protocol_compatibility(status) - if _srv().daemon_runtime_owned_by_current_source(status) and compatible is True: + compatible, _ = daemon_protocol.daemon_protocol_compatibility(status) + if daemon_protocol.daemon_runtime_owned_by_current_source(status) and compatible is True: _daemon_upgrade_request_path(life_dir).unlink(missing_ok=True) return {"rc": 0, "upgraded": False, "reason": "daemon is already current"} expected_pid = int(request.get("expected_pid") or 0) - if status.pid != expected_pid or not _srv().daemon_runtime_owned_by_current_source(status): + if status.pid != expected_pid or not daemon_protocol.daemon_runtime_owned_by_current_source(status): error = "daemon identity changed before the scheduled drain" _record_daemon_upgrade_error(life_dir, request, error) return {"rc": 2, "error": error} - stop_rc = _srv().stop_daemon( + stop_rc = daemon_worker.stop_daemon( life_dir, drain=True, drain_timeout=0.0, @@ -202,7 +200,7 @@ def _complete_scheduled_daemon_upgrade( _record_daemon_upgrade_error(life_dir, request, error) return {"rc": 2, "error": error} - with _srv().daemon_command_execution_lock(life_dir) as acquired: + with daemon_commands.daemon_command_execution_lock(life_dir) as acquired: if not acquired: return {"rc": 2, "error": "daemon command lock unavailable"} request = _read_daemon_upgrade_request(life_dir) @@ -212,10 +210,10 @@ def _complete_scheduled_daemon_upgrade( "upgraded": False, "reason": "upgrade was cancelled by a newer daemon command", } - status = _srv().read_daemon_status(life_dir) + status = daemon_worker.read_daemon_status(life_dir) if status.alive: - compatible, _ = _srv().daemon_protocol_compatibility(status) - if _srv().daemon_runtime_owned_by_current_source(status) and compatible is True: + compatible, _ = daemon_protocol.daemon_protocol_compatibility(status) + if daemon_protocol.daemon_runtime_owned_by_current_source(status) and compatible is True: _daemon_upgrade_request_path(life_dir).unlink(missing_ok=True) return { "rc": 0, @@ -229,12 +227,12 @@ def _complete_scheduled_daemon_upgrade( resume_continuous = bool(request.get("resume_continuous")) objective = str(request.get("objective") or "") if resume_continuous: - _srv().write_continuous_config( + daemon_worker.write_continuous_config( life_dir, enabled=True, objective=objective, ) - started = _srv().start_project_daemon( + started = daemon_lifecycle.start_project_daemon( sid, global_root=_global_root(global_root), resume_continuous=resume_continuous, @@ -270,24 +268,24 @@ def schedule_project_daemon_upgrade( } reason = str(request.get("reason") or "pending daemon upgrade") else: - status = _srv().read_daemon_status(life_dir) - compatible, reason = _srv().daemon_protocol_compatibility(status) + status = daemon_worker.read_daemon_status(life_dir) + compatible, reason = daemon_protocol.daemon_protocol_compatibility(status) if not status.alive or status.pid is None: return {"rc": 0, "scheduled": False, "reason": "daemon is not running"} if compatible is not False: return {"rc": 0, "scheduled": False, "reason": "daemon is current"} - if not _srv().daemon_runtime_owned_by_current_source(status): + if not daemon_protocol.daemon_runtime_owned_by_current_source(status): return { "rc": 0, "scheduled": False, "reason": "daemon belongs to a different Argus installation", } - continuous = _srv().read_continuous_state(life_dir) + continuous = daemon_worker.read_continuous_state(life_dir) request = { "schema_version": 1, "sid": sid, "expected_pid": status.pid, - "source_root": str(_srv().runtime_identity().get("source_root") or ""), + "source_root": str(runtime_identity_module.runtime_identity().get("source_root") or ""), "resume_continuous": bool(continuous.enabled), "objective": str(continuous.objective or ""), "reason": reason, @@ -311,7 +309,7 @@ def _run() -> None: if result.get("draining") is True: timer = threading.Timer( 5.0, - lambda: _srv().schedule_project_daemon_upgrade( + lambda: schedule_project_daemon_upgrade( sid, global_root=global_root, ), @@ -358,7 +356,7 @@ def reconcile_pending_daemon_upgrades( if not project_state.daemon_upgrade_pending(life_dir): continue try: - result = _srv().schedule_project_daemon_upgrade( + result = schedule_project_daemon_upgrade( life_dir.name, global_root=root, ) diff --git a/argus/webapi/mission_items.py b/argus/webapi/mission_items.py index 18f63624a..61b74faf5 100644 --- a/argus/webapi/mission_items.py +++ b/argus/webapi/mission_items.py @@ -1,8 +1,4 @@ -"""Work-item queueing, config, and read-only diagnostic queries. - -Extracted from ``server.py`` as part of a behavior-preserving decomposition. -Public names remain re-exported from ``server`` for backward compatibility. -""" +"""Work-item queueing, configuration, and read-only diagnostic queries.""" from __future__ import annotations diff --git a/argus/webapi/project_crud.py b/argus/webapi/project_crud.py index 21d36f464..e5a9aa87a 100644 --- a/argus/webapi/project_crud.py +++ b/argus/webapi/project_crud.py @@ -1,8 +1,4 @@ -"""Project/session CRUD operations for the webapi server. - -Extracted from ``server.py`` as part of a behavior-preserving decomposition. -Public names remain re-exported from ``server`` for backward compatibility. -""" +"""Project/session updates, trash, restore, and continuous-mode operations.""" from __future__ import annotations diff --git a/argus/webapi/routes/__init__.py b/argus/webapi/routes/__init__.py index b9443dc12..054129846 100644 --- a/argus/webapi/routes/__init__.py +++ b/argus/webapi/routes/__init__.py @@ -1,10 +1,4 @@ -"""Per-domain FastAPI route registrars used by :func:`argus.webapi.server.create_app`. +"""HTTP route registrars accept the app and its ServerContext. -Each sibling module exposes a single ``register_*_routes(app, ctx, server_mod)`` -function that attaches one API domain's endpoints to the app. This package is -only ever imported lazily from inside ``create_app`` (after FastAPI has -already been imported there), so its modules are free to import ``fastapi`` / -``pydantic`` at module scope without breaking the optional ``[web]`` extra -contract described in :mod:`argus.webapi.server` — importing this -package itself (with no submodule touched) stays free of that requirement. -""" +Routes call the owning service modules directly; daemon services and query +workers remain isolated in the context constructed by create_app.""" diff --git a/argus/webapi/routes/artifacts.py b/argus/webapi/routes/artifacts.py index 54c4f1e1f..7facfc5c6 100644 --- a/argus/webapi/routes/artifacts.py +++ b/argus/webapi/routes/artifacts.py @@ -1,7 +1,5 @@ """artifacts/read-only API domain: project artifact listing, artifact detail, raw artifact file serving, and git-diff. - -See :mod:`.meta` for the extraction convention this module follows. """ from __future__ import annotations @@ -11,6 +9,7 @@ from fastapi import Depends, Header, HTTPException, Query, Response from starlette.responses import FileResponse +from .. import artifacts from .context import ServerContext # The preview page sandboxes itself and denies every network destination, so @@ -25,7 +24,7 @@ ) -def register_artifact_routes(app, ctx: ServerContext, server_mod) -> None: +def register_artifact_routes(app, ctx: ServerContext) -> None: @app.get( "/api/projects/{sid}/artifacts", dependencies=[Depends(ctx.require_auth)], @@ -34,7 +33,7 @@ def _artifacts(sid: str, response: Response, include_reading: bool = False) -> d response.headers["Cache-Control"] = "private, no-store" return { "artifacts": ctx.not_found_if_none( - server_mod.list_project_artifacts( + artifacts.list_project_artifacts( sid, global_root=ctx.project_root_or_404(sid), **({"include_reading": True} if include_reading else {}), ), @@ -52,7 +51,7 @@ def _artifact( path: str = Query(..., min_length=1), ) -> dict[str, Any]: response.headers["Cache-Control"] = "private, no-store" - artifact = server_mod.get_project_artifact( + artifact = artifacts.get_project_artifact( sid, path, global_root=ctx.project_root_or_404(sid) ) if artifact is None: @@ -68,7 +67,7 @@ def _artifact_raw( path: str = Query(..., min_length=1), download: bool = Query(False), ): - resolved = server_mod._resolved_project_artifact( + resolved = artifacts.resolved_project_artifact( sid, path, global_root=ctx.project_root_or_404(sid) ) if resolved is None: @@ -97,7 +96,7 @@ def _artifact_raw( def html_package(sid: str, path: str): from ..artifact_preview import HtmlPackage - resolved = server_mod._resolved_project_artifact( + resolved = artifacts.resolved_project_artifact( sid, path, global_root=ctx.project_root_or_404(sid) ) if resolved is None or resolved[0]["kind"] != "html": @@ -161,6 +160,6 @@ def _artifact_bundle(sid: str, path: str = Query(..., min_length=1)): def _git_diff(sid: str, response: Response) -> dict[str, Any]: response.headers["Cache-Control"] = "private, no-store" return ctx.not_found_if_none( - server_mod._project_git_diff(sid, global_root=ctx.project_root_or_404(sid)), + artifacts.project_git_diff(sid, global_root=ctx.project_root_or_404(sid)), sid, ) diff --git a/argus/webapi/routes/context.py b/argus/webapi/routes/context.py index b22159ad8..257b8edb7 100644 --- a/argus/webapi/routes/context.py +++ b/argus/webapi/routes/context.py @@ -1,22 +1,9 @@ -"""Shared per-request helpers threaded into every route domain registrar. - -``ServerContext`` bundles the small pool of closures that ``create_app`` used -to define inline (auth check, project-root resolution, machine-wide project -listing) so each domain module gets identical behavior without duplicating -it. Built once per ``create_app`` call and passed by reference — cheap and -side-effect free to construct. - -This module imports ``fastapi`` at module scope. That is safe here because it -is only ever imported lazily, from inside ``create_app`` (see -:mod:`argus.webapi.server`), well after FastAPI has already been -imported there — never from top-level package/module import, so the optional -``[web]`` extra contract is preserved. -""" +"""Per-app authentication, root resolution, caches, and query services.""" from __future__ import annotations from pathlib import Path -from typing import Any, Callable +from typing import Any, Callable, Iterator from fastapi import Header, HTTPException @@ -52,31 +39,8 @@ def __init__( self._list_project_costs = list_project_costs self._list_trashed_projects = list_trashed_projects self._project_life_dir = project_life_dir - self._index_cache = IndexCache() - self._snapshot_cache = IndexCache(ttl_seconds=resolve_snapshot_ttl_seconds()) - - @property - def index_cache(self) -> IndexCache: - """Coalescing cache for the whole-home listings the cockpit polls. - - Real app contexts build this eagerly so concurrent first requests - cannot race into separate caches. The fallback keeps listing helpers - usable from lightweight test subclasses that predate this shared state. - """ - cache = getattr(self, "_index_cache", None) - if cache is None: - cache = IndexCache() - self._index_cache = cache - return cache - - @property - def snapshot_cache(self) -> IndexCache: - """Coalescing cache for expensive per-session cockpit snapshots.""" - cache = getattr(self, "_snapshot_cache", None) - if cache is None: - cache = IndexCache(ttl_seconds=resolve_snapshot_ttl_seconds()) - self._snapshot_cache = cache - return cache + self.index_cache = IndexCache() + self.snapshot_cache = IndexCache(ttl_seconds=resolve_snapshot_ttl_seconds()) def invalidate_read_caches(self) -> None: """Detach cached and in-flight reads after a successful mutation.""" @@ -150,41 +114,19 @@ def _machine_projects_uncached( limit: int, include_empty: bool, ) -> list[dict[str, Any]]: - projects: list[dict[str, Any]] = [] - seen: set[str] = set() - for root in self.roots: - try: - root_session_ids = { - path.name - for path in core_paths.session_states_root(root).iterdir() - if path.is_dir() - } - except OSError: - root_session_ids = set() - root_limit = limit + len(seen.intersection(root_session_ids)) - for project in self._list_projects( - global_root=root, - limit=root_limit, - include_empty=include_empty, + projects = [] + for root, project in self._project_rows( + self._list_projects, limit=limit, include_empty=include_empty, + ): + # Hide metadata-free legacy directories unless their daemon is live. + sid = str(project["id"]) + if ( + not sid.startswith("s-") + and not (core_paths.session_state_root(sid, root=root) / "session.json").is_file() + and not bool(project.get("daemon_alive")) ): - sid = str(project.get("id") or "") - if not sid or sid in seen: - continue - # The sidebar lists sessions with stable metadata. Keep a - # metadata-free entry only while its daemon is live, because - # hiding active work would also hide its stop controls. - if ( - not sid.startswith("s-") - and not ( - core_paths.session_state_root(sid, root=root) / "session.json" - ).is_file() - and not bool(project.get("daemon_alive")) - ): - continue - projects.append(project) - # Routing uses the first root containing an ID, so reserve every ID - # from that root even when its session is empty or outside `limit`. - seen.update(root_session_ids) + continue + projects.append(project) projects.sort( key=lambda project: float(project.get("last_active") or 0.0), reverse=True, @@ -205,29 +147,34 @@ async def machine_project_costs_async(self, *, limit: int) -> list[dict[str, Any ) def _machine_project_costs_uncached(self, *, limit: int) -> list[dict[str, Any]]: - costs: list[dict[str, Any]] = [] + return [ + row for _root, row in self._project_rows( + self._list_project_costs, limit=limit, include_empty=False, + ) + ][:limit] + + def _project_rows( + self, read: Callable[..., list[dict[str, Any]]], *, limit: int, include_empty: bool, + ) -> Iterator[tuple[Path, dict[str, Any]]]: + """Apply the same first-root ownership rule to project and cost lists.""" seen: set[str] = set() for root in self.roots: try: - root_session_ids = { - path.name - for path in core_paths.session_states_root(root).iterdir() + root_ids = { + path.name for path in core_paths.session_states_root(root).iterdir() if path.is_dir() } except OSError: - root_session_ids = set() - root_limit = limit + len(seen.intersection(root_session_ids)) - for row in self._list_project_costs( - global_root=root, - limit=root_limit, - include_empty=False, + root_ids = set() + for row in read( + global_root=root, limit=limit + len(seen.intersection(root_ids)), + include_empty=include_empty, ): sid = str(row.get("id") or "") - if not sid or sid in seen: - continue - costs.append(row) - seen.update(root_session_ids) - return costs[:limit] + if sid and sid not in seen: + yield root, row + # Reserve even empty/limited-out sessions: routing also picks the first root. + seen.update(root_ids) def machine_trash(self) -> list[dict[str, Any]]: return self.index_cache.get( diff --git a/argus/webapi/routes/counterexamples.py b/argus/webapi/routes/counterexamples.py index 05844fecc..adbdd0574 100644 --- a/argus/webapi/routes/counterexamples.py +++ b/argus/webapi/routes/counterexamples.py @@ -6,20 +6,21 @@ from fastapi import Depends, HTTPException +from .. import artifacts, counterexample_dashboard from .context import ServerContext -def register_counterexample_routes(app, ctx: ServerContext, server_mod) -> None: +def register_counterexample_routes(app, ctx: ServerContext) -> None: @app.get( "/api/projects/{sid}/counterexamples", dependencies=[Depends(ctx.require_auth)], ) def _counterexamples(sid: str) -> dict[str, Any]: root = ctx.project_root_or_404(sid) - workspace = server_mod._project_workspace(sid, global_root=root) + workspace = artifacts.project_workspace(sid, global_root=root) if workspace is None: raise HTTPException(status_code=404, detail=f"project workspace unavailable: {sid}") - return server_mod.build_counterexample_dashboard(workspace) + return counterexample_dashboard.build_counterexample_dashboard(workspace) __all__ = ["register_counterexample_routes"] diff --git a/argus/webapi/routes/daemon.py b/argus/webapi/routes/daemon.py index e68f70f7c..5e26b12e0 100644 --- a/argus/webapi/routes/daemon.py +++ b/argus/webapi/routes/daemon.py @@ -1,23 +1,47 @@ -"""daemon control API domain: creating daemons/sessions and starting, -stopping, replacing, and upgrading a project's executor, plus continuous -(7x24) mode toggling. - -See :mod:`.meta` for the extraction convention this module follows. -""" +"""HTTP daemon creation, start/stop, replacement, upgrades, and continuous mode.""" from __future__ import annotations -from typing import Any +from pathlib import Path +from typing import Any, Callable from fastapi import Depends, HTTPException from starlette.concurrency import run_in_threadpool from ...core.workspace_lease import canonical_workdir +from ...daemon import commands as daemon_commands from ...life.memory import LifeMemory +from ...manager import front_door as manager_front_door +from .. import daemon_lifecycle, daemon_upgrade, project_state from .context import ServerContext from .models import CommandIn, ContinuousIn, CreateDaemonIn, ReplaceDaemonIn, StopIn +async def _execute_command( + path: Path, command: CommandIn, *, operation: str, args: dict[str, Any], + handler: Callable[[], dict[str, Any]], +) -> dict[str, Any]: + receipt = await run_in_threadpool( + daemon_commands.execute_daemon_command, path, + operation=operation, args=args, handler=handler, + command_id=command.command_id or None, + expected_revision=command.expected_revision, issuer="webapi", + ) + result = dict(receipt.result) + if receipt.status in {"failed", "rejected"}: + result.setdefault("rc", 3) + result.setdefault("error", receipt.error) + result.update( + { + "command_id": receipt.command_id, + "command_status": receipt.status, + "command_revision": receipt.revision, + "command": receipt.to_jsonable(), + } + ) + return result + + def _resume_provider_fences_after_start(life_dir, result, *, enabled=True): # Only an authenticated, explicit start/continue command reaches this # callback. Automatic supervision/restarts never clear this boundary. @@ -27,13 +51,13 @@ def _resume_provider_fences_after_start(life_dir, result, *, enabled=True): return result -def register_daemon_routes(app, ctx: ServerContext, server_mod) -> None: +def register_daemon_routes(app, ctx: ServerContext) -> None: @app.post("/api/daemons", dependencies=[Depends(ctx.require_auth)]) async def _create_daemon(body: CreateDaemonIn) -> dict[str, Any]: """Create a brand-new daemon (session). The objective is OPTIONAL — with none, the daemon is idle and the user just talks to the Manager (which writes its own objectives). Threadpool: fs writes + optional fork.""" - root = server_mod._global_root(ctx.global_root) + root = project_state.resolve_global_root(ctx.global_root) resolved_paths: dict[str, str] = {} for label, value in ( ("workdir", body.workdir), @@ -49,9 +73,8 @@ async def _create_daemon(body: CreateDaemonIn) -> dict[str, Any]: status_code=400, detail=f"{label} is unavailable: {value}", ) from exc - receipt = await run_in_threadpool( - server_mod.execute_daemon_command, - root, + return await _execute_command( + root, body, operation="create", args={ "objective": body.objective, @@ -59,10 +82,7 @@ async def _create_daemon(body: CreateDaemonIn) -> dict[str, Any]: "launch_cwd": resolved_paths["launch cwd"], "workdir": resolved_paths["workdir"], }, - command_id=body.command_id or None, - expected_revision=body.expected_revision, - issuer="webapi", - handler=lambda: server_mod.create_daemon( + handler=lambda: daemon_lifecycle.create_daemon( body.objective, name=body.name, launch_cwd=resolved_paths["launch cwd"], @@ -70,7 +90,6 @@ async def _create_daemon(body: CreateDaemonIn) -> dict[str, Any]: global_root=ctx.global_root, ), ) - return server_mod._command_response(receipt) @app.post("/api/projects/{sid}/daemon/start", dependencies=[Depends(ctx.require_auth)]) async def _daemon_start( @@ -92,17 +111,12 @@ def start_and_resume() -> dict[str, Any]: ) return _resume_provider_fences_after_start(life_dir, result) - receipt = await run_in_threadpool( - server_mod.execute_daemon_command, - life_dir, + return await _execute_command( + life_dir, command, operation="start", args={"resume_continuous": True}, - command_id=command.command_id or None, - expected_revision=command.expected_revision, - issuer="webapi", handler=start_and_resume, ) - return server_mod._command_response(receipt) @app.post("/api/projects/{sid}/daemon/stop", dependencies=[Depends(ctx.require_auth)]) async def _daemon_stop(sid: str, body: StopIn | None = None) -> dict[str, Any]: @@ -110,16 +124,12 @@ async def _daemon_stop(sid: str, body: StopIn | None = None) -> dict[str, Any]: life_dir = ctx.resolve_or_404(sid) project_root = ctx.project_root_or_404(sid) operation = "kill" if b.force else "drain" if b.drain else "stop" - receipt = await run_in_threadpool( - server_mod.execute_daemon_command, - life_dir, + return await _execute_command( + life_dir, b, operation=operation, args={"drain": b.drain, "force": b.force}, - command_id=b.command_id or None, - expected_revision=b.expected_revision, - issuer="webapi", handler=lambda: ctx.not_found_if_none( - server_mod.stop_project_daemon( + daemon_lifecycle.stop_project_daemon( sid, drain=b.drain, force=b.force, @@ -128,7 +138,6 @@ async def _daemon_stop(sid: str, body: StopIn | None = None) -> dict[str, Any]: sid, ), ) - return server_mod._command_response(receipt) @app.post("/api/projects/{sid}/daemon/replace", dependencies=[Depends(ctx.require_auth)]) async def _daemon_replace(sid: str, body: ReplaceDaemonIn) -> dict[str, Any]: @@ -137,7 +146,7 @@ async def _daemon_replace(sid: str, body: ReplaceDaemonIn) -> dict[str, Any]: def replace_and_resume() -> dict[str, Any]: result = ctx.not_found_if_none( - server_mod.replace_project_daemon( + daemon_lifecycle.replace_project_daemon( sid, body.victim_sid, global_root=project_root, @@ -149,20 +158,15 @@ def replace_and_resume() -> dict[str, Any]: life_dir, result, enabled=body.resume_continuous, ) - receipt = await run_in_threadpool( - server_mod.execute_daemon_command, - life_dir, + return await _execute_command( + life_dir, body, operation="replace", args={ "victim_sid": body.victim_sid, "resume_continuous": body.resume_continuous, }, - command_id=body.command_id or None, - expected_revision=body.expected_revision, - issuer="webapi", handler=replace_and_resume, ) - return server_mod._command_response(receipt) @app.post("/api/projects/{sid}/daemon/upgrade", dependencies=[Depends(ctx.require_auth)]) async def _daemon_upgrade( @@ -172,20 +176,15 @@ async def _daemon_upgrade( command = body or CommandIn() life_dir = ctx.resolve_or_404(sid) project_root = ctx.project_root_or_404(sid) - receipt = await run_in_threadpool( - server_mod.execute_daemon_command, - life_dir, + return await _execute_command( + life_dir, command, operation="upgrade", args={}, - command_id=command.command_id or None, - expected_revision=command.expected_revision, - issuer="webapi", handler=lambda: ctx.not_found_if_none( - server_mod.upgrade_project_daemon(sid, global_root=project_root), + daemon_upgrade.upgrade_project_daemon(sid, global_root=project_root), sid, ), ) - return server_mod._command_response(receipt) @app.post( "/api/projects/{sid}/daemon/upgrade-schedule", @@ -198,20 +197,15 @@ async def _daemon_upgrade_schedule( command = body or CommandIn() life_dir = ctx.resolve_or_404(sid) project_root = ctx.project_root_or_404(sid) - receipt = await run_in_threadpool( - server_mod.execute_daemon_command, - life_dir, + return await _execute_command( + life_dir, command, operation="upgrade", args={"scheduled": True}, - command_id=command.command_id or None, - expected_revision=command.expected_revision, - issuer="webapi", handler=lambda: ctx.not_found_if_none( - server_mod.schedule_project_daemon_upgrade(sid, global_root=project_root), + daemon_upgrade.schedule_project_daemon_upgrade(sid, global_root=project_root), sid, ), ) - return server_mod._command_response(receipt) @app.post("/api/projects/{sid}/continuous", dependencies=[Depends(ctx.require_auth)]) async def _post_continuous(sid: str, body: ContinuousIn) -> dict[str, Any]: @@ -236,9 +230,9 @@ async def _post_continuous(sid: str, body: ContinuousIn) -> dict[str, Any]: start=ctx.daemon_services.start, global_root=project_root, ) return response - except server_mod.ManagerHandoffSupersededError as exc: + except manager_front_door.ManagerHandoffSupersededError as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc - except server_mod.ManagerHandoffError as exc: + except manager_front_door.ManagerHandoffError as exc: raise HTTPException(status_code=503, detail=str(exc)) from exc except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc diff --git a/argus/webapi/routes/manager.py b/argus/webapi/routes/manager.py index 3121b1aae..c9d5528b7 100644 --- a/argus/webapi/routes/manager.py +++ b/argus/webapi/routes/manager.py @@ -1,8 +1,6 @@ """Manager streaming/messages API domain: the Manager front-door chat endpoints (blocking + SSE-streaming twins) and the live project event WebSocket stream. - -See :mod:`.meta` for the extraction convention this module follows. """ from __future__ import annotations @@ -18,6 +16,8 @@ from starlette.concurrency import run_in_threadpool from starlette.responses import StreamingResponse +from ...daemon import life_worker as daemon_worker +from .. import project_state, server from .context import ServerContext from .models import CancelMessageIn, MessageIn @@ -133,7 +133,7 @@ async def _read_uploaded_attachments( return payload -def register_manager_routes(app, ctx: ServerContext, server_mod) -> None: +def register_manager_routes(app, ctx: ServerContext) -> None: from ..manager_state import manager_control_generation from ..message_requests import ( MessageRequestCancelled, @@ -169,7 +169,7 @@ def _visible_daemon(sid: str) -> dict[str, Any]: from ..project_state import daemon_dict life_dir = ctx.resolve_or_404(sid) - return daemon_dict(server_mod.read_daemon_status(life_dir), life_dir=life_dir) + return daemon_dict(daemon_worker.read_daemon_status(life_dir), life_dir=life_dir) def _record_spawn_result(result: dict[str, Any], spawned: Any) -> None: result["daemon"] = spawned @@ -445,9 +445,9 @@ def _on_terminal(payload: dict) -> None: raise def _gen(): - for item in server_mod._iter_manager_stream_items( + for item in server._iter_manager_stream_items( q, - heartbeat_s=server_mod._manager_stream_heartbeat_seconds(), + heartbeat_s=server._manager_stream_heartbeat_seconds(), ): if stream_closed.is_set(): break @@ -466,7 +466,7 @@ async def _stream(ws: WebSocket, sid: str, replay: int = 40, token_q: str | None = Query(default=None, alias="token")) -> None: project_root = ctx.root_for_project(sid) life_dir = ( - server_mod.project_life_dir(sid, global_root=project_root) + project_state.project_life_dir(sid, global_root=project_root) if project_root is not None else None ) @@ -477,7 +477,7 @@ async def _stream(ws: WebSocket, sid: str, replay: int = 40, if life_dir is None: await ws.close(code=4404, reason="unknown project") return - iterator = server_mod.tail_events( + iterator = server.tail_events( life_dir, replay_limit=max(0, min(replay, 200)), ).__aiter__() @@ -500,7 +500,7 @@ async def _stream(ws: WebSocket, sid: str, replay: int = 40, except StopAsyncIteration: return event_task = asyncio.create_task(anext(iterator)) - if view == "ui" and not server_mod._event_visible_in_web_ui(ev): + if view == "ui" and not server._event_visible_in_web_ui(ev): continue await ws.send_json(ev) except asyncio.CancelledError: diff --git a/argus/webapi/routes/meta.py b/argus/webapi/routes/meta.py index aeb7aa324..cddce7aac 100644 --- a/argus/webapi/routes/meta.py +++ b/argus/webapi/routes/meta.py @@ -1,14 +1,4 @@ -"""config/diagnostics API domain: ``/api/meta``, metrics, per-project config, -identity, doctor, and the operator config/budget/identity/reset/skills -command endpoints. - -Registered by :func:`argus.webapi.server.create_app`. Every handler -below is a straight extraction of the corresponding nested function that used -to live inside ``create_app`` — bodies are unchanged; only the enclosing -scope moved from a closure over ``create_app`` locals to parameters passed -in explicitly (``ctx`` for shared auth/root helpers, ``server_mod`` for the -module-level functions ``create_app`` re-exports/defines). -""" +"""HTTP configuration, metrics, identity, diagnostics, and operator settings.""" from __future__ import annotations @@ -19,6 +9,8 @@ from fastapi import Depends, Header, HTTPException, Request, Response +from ...core import metrics +from .. import mission_items, project_state, source_update from .context import ServerContext from .models import BudgetSetIn, ConfigSetIn, CostAcknowledgeIn, IdentitySetIn, SkillsIn @@ -88,7 +80,7 @@ def _resource_status_payload(snapshot: dict[str, Any], *, now: float) -> dict[st }) -def register_meta_routes(app, ctx: ServerContext, server_mod) -> None: +def register_meta_routes(app, ctx: ServerContext) -> None: token = ctx.token api_meta = ctx.api_meta @@ -122,13 +114,13 @@ def _meta( @app.get("/api/metrics", dependencies=[Depends(ctx.require_auth)]) def _metrics() -> dict[str, Any]: - return server_mod.metrics_snapshot(root=server_mod._global_root(ctx.global_root)) + return metrics.metrics_snapshot(root=project_state.resolve_global_root(ctx.global_root)) @app.get("/metrics", dependencies=[Depends(ctx.require_auth)]) def _prometheus_metrics() -> Response: - snapshot = server_mod.metrics_snapshot(root=server_mod._global_root(ctx.global_root)) + snapshot = metrics.metrics_snapshot(root=project_state.resolve_global_root(ctx.global_root)) return Response( - server_mod.render_prometheus(snapshot), + metrics.render_prometheus(snapshot), media_type="text/plain; version=0.0.4; charset=utf-8", ) @@ -137,7 +129,7 @@ def _prometheus_metrics() -> Response: dependencies=[Depends(ctx.require_auth)], ) def _config(sid: str) -> dict[str, Any]: - return server_mod.get_config( + return mission_items.get_config( project_state_dir=ctx.resolve_or_404(sid), global_root=ctx.project_root_or_404(sid), ) @@ -149,7 +141,7 @@ def _config(sid: str) -> dict[str, Any]: def _identity(sid: str) -> dict[str, Any]: return { "identity": ctx.not_found_if_none( - server_mod.get_identity(sid, global_root=ctx.project_root_or_404(sid)), sid + mission_items.get_identity(sid, global_root=ctx.project_root_or_404(sid)), sid ) } @@ -159,7 +151,7 @@ def _identity(sid: str) -> dict[str, Any]: ) def _doctor(sid: str) -> dict[str, Any]: return ctx.not_found_if_none( - server_mod.get_doctor(sid, global_root=ctx.project_root_or_404(sid)), sid + mission_items.get_doctor(sid, global_root=ctx.project_root_or_404(sid)), sid ) @app.get("/api/system/doctor", dependencies=[Depends(ctx.require_auth)]) @@ -171,7 +163,7 @@ def _system_doctor(request: Request) -> dict[str, Any]: from ...core.runtime_identity import source_root from ...maintenance.doctor import DoctorContext, run_full_doctor - root = server_mod._global_root(ctx.global_root) + root = project_state.resolve_global_root(ctx.global_root) source = source_root() checkout = source if (source / "pyproject.toml").is_file() else None web_host, web_port = request.scope["server"] @@ -195,8 +187,8 @@ def _system_doctor(request: Request) -> dict[str, Any]: @app.get("/api/runtime/source-update", dependencies=[Depends(ctx.require_auth)]) def _source_update_status() -> dict[str, Any]: - return server_mod.read_source_update_status( - server_mod._global_root(ctx.global_root) + return source_update.read_source_update_status( + project_state.resolve_global_root(ctx.global_root) ) @app.post( @@ -204,8 +196,8 @@ def _source_update_status() -> dict[str, Any]: dependencies=[Depends(ctx.require_auth)], ) def _source_update_check() -> dict[str, Any]: - return server_mod.start_source_update( - server_mod._global_root(ctx.global_root), + return source_update.start_source_update( + project_state.resolve_global_root(ctx.global_root), action="check", ) @@ -214,8 +206,8 @@ def _source_update_check() -> dict[str, Any]: dependencies=[Depends(ctx.require_auth)], ) def _source_update_apply() -> dict[str, Any]: - return server_mod.start_source_update( - server_mod._global_root(ctx.global_root), + return source_update.start_source_update( + project_state.resolve_global_root(ctx.global_root), action="apply", ) @@ -231,7 +223,7 @@ def _config_set(sid: str, body: ConfigSetIn) -> dict[str, Any]: project_state_dir = ctx.resolve_or_404(sid) root = ctx.project_root_or_404(sid) try: - return server_mod.set_operator_config( + return mission_items.set_operator_config( body.name, body.value, project_state_dir=project_state_dir, @@ -251,7 +243,7 @@ def _budget_set(sid: str, body: BudgetSetIn) -> dict[str, Any]: project_state_dir = ctx.resolve_or_404(sid) root = ctx.project_root_or_404(sid) try: - return server_mod.set_budget_config( + return mission_items.set_budget_config( body.values, project_state_dir=project_state_dir, global_root=root, @@ -303,7 +295,7 @@ def _acknowledge_cost(sid: str, body: CostAcknowledgeIn) -> dict[str, Any]: @app.post("/api/projects/{sid}/identity", dependencies=[Depends(ctx.require_auth)]) def _identity_set(sid: str, body: IdentitySetIn) -> dict[str, Any]: ctx.not_found_if_none( - server_mod.set_identity( + mission_items.set_identity( sid, body.text, global_root=ctx.project_root_or_404(sid) ), sid, @@ -326,7 +318,7 @@ def _skills(sid: str, body: SkillsIn) -> dict[str, Any]: tokens = shlex.split(body.args) except ValueError as exc: raise HTTPException(status_code=400, detail=f"invalid skill arguments: {exc}") from exc - return {"text": server_mod.run_skill_command( + return {"text": mission_items.run_skill_command( tokens, global_root=root, project_state=state, workdir=project_workspace(sid, global_root=root), )} diff --git a/argus/webapi/routes/projects.py b/argus/webapi/routes/projects.py index ec29da121..5786caada 100644 --- a/argus/webapi/routes/projects.py +++ b/argus/webapi/routes/projects.py @@ -1,29 +1,21 @@ -"""projects/sessions API domain: project listing/CRUD, trash, and per-project -snapshot/event reads. - -Per-session work-item endpoints (tasks, nudges, pending question answers, -notes, plan preview, backlog item read/dispose/stop, mission abort, -status/journal/transcript reads) live in the sibling :mod:`.workitems` -module — this file stayed too big as a single registrar (breaching the -per-function size budget) so it was split along a real seam: project-level -listing/CRUD vs. per-mission work-item operations. - -See :mod:`.meta` for the extraction convention this module follows. -""" +"""HTTP project listing, CRUD, trash, snapshots, and event reads.""" from __future__ import annotations +import time from typing import Any from fastapi import Depends, HTTPException, Query from starlette.concurrency import run_in_threadpool -from .. import project_crud +from ...apps.cli import _follow as event_follow +from ...life import memory as life_memory +from .. import daemon_lifecycle, project_crud, project_state, server from .context import ServerContext from .models import LaunchCwdIn, ProjectUpdateIn, WorkdirIn -def register_project_routes(app, ctx: ServerContext, server_mod) -> None: +def register_project_routes(app, ctx: ServerContext) -> None: @app.get("/api/projects", dependencies=[Depends(ctx.require_auth)]) async def _projects( limit: int = Query(100, ge=1, le=2000), @@ -40,7 +32,7 @@ async def _project_costs( ) -> dict[str, Any]: return { "projects": await ctx.machine_project_costs_async(limit=limit), - "generated_at": server_mod.time.time(), + "generated_at": time.time(), } @app.get("/api/trash", dependencies=[Depends(ctx.require_auth)]) @@ -94,7 +86,7 @@ async def _restore_trash(trash_id: str) -> dict[str, Any]: if entry is None: raise HTTPException(status_code=404, detail="unknown trash entry") if any( - server_mod.project_life_dir(str(entry["sid"]), global_root=root) is not None + project_state.project_life_dir(str(entry["sid"]), global_root=root) is not None for root in ctx.roots ): raise HTTPException( @@ -116,7 +108,7 @@ async def _restore_trash(trash_id: str) -> dict[str, Any]: @app.post("/api/projects/{sid}/launch-cwd", dependencies=[Depends(ctx.require_auth)]) async def _set_launch_cwd(sid: str, body: LaunchCwdIn) -> dict[str, bool]: updated = await run_in_threadpool( - server_mod.set_project_launch_cwd, + daemon_lifecycle.set_project_launch_cwd, sid, body.launch_cwd, global_root=ctx.project_root_or_404(sid), @@ -133,7 +125,7 @@ async def _set_launch_cwd(sid: str, body: LaunchCwdIn) -> dict[str, bool]: @app.post("/api/projects/{sid}/workdir", dependencies=[Depends(ctx.require_auth)]) async def _set_workdir(sid: str, body: WorkdirIn) -> dict[str, Any]: result = await run_in_threadpool( - server_mod.set_project_workdir, + daemon_lifecycle.set_project_workdir, sid, body.workdir, global_root=ctx.project_root_or_404(sid), @@ -194,7 +186,7 @@ async def _snapshot( pass def _build_snapshot() -> dict[str, Any] | None: - return server_mod.build_snapshot( + return project_state.build_snapshot( sid, global_root=root, events_limit=events_limit, @@ -229,11 +221,11 @@ def _events( ) -> dict[str, Any]: life_dir = ctx.resolve_or_404(sid) if view == "ui": - events = server_mod._read_jsonl_tail_history( - life_dir / server_mod.EVENT_FILE, + events = life_memory._read_jsonl_tail_history( + life_dir / server.EVENT_FILE, limit, - predicate=server_mod._event_visible_in_web_ui, + predicate=server._event_visible_in_web_ui, ) else: - events = server_mod._read_recent_project_events(life_dir, limit=limit) + events = event_follow._read_recent_project_events(life_dir, limit=limit) return {"events": events} diff --git a/argus/webapi/routes/workitems.py b/argus/webapi/routes/workitems.py index b0430737b..1cb87dd33 100644 --- a/argus/webapi/routes/workitems.py +++ b/argus/webapi/routes/workitems.py @@ -15,7 +15,7 @@ from starlette.concurrency import run_in_threadpool from ...manager.front_door import ManagerHandoffError, ManagerHandoffSupersededError -from .. import mission_items +from .. import manager_pending_question, mission_items from .context import ServerContext from .models import ( AbortMissionIn, @@ -30,7 +30,7 @@ ) -def register_workitem_routes(app, ctx: ServerContext, server_mod) -> None: +def register_workitem_routes(app, ctx: ServerContext) -> None: @app.post("/api/projects/{sid}/tasks", dependencies=[Depends(ctx.require_auth)]) async def _post_task(sid: str, body: TaskIn) -> dict[str, Any]: if not body.text.strip(): @@ -94,7 +94,7 @@ async def _answer_pending( raise HTTPException(status_code=400, detail="empty answer") project_root = ctx.project_root_or_404(sid) result = await run_in_threadpool( - server_mod.answer_pending_question, + manager_pending_question.manager_answer_pending_question, sid, item_id, body.text, @@ -124,7 +124,7 @@ async def _resolve_decision( ) -> dict[str, Any]: project_root = ctx.project_root_or_404(sid) result = await run_in_threadpool( - server_mod.resolve_operator_decision, + manager_pending_question.manager_resolve_operator_decision, sid, decision_id, body.option_id, diff --git a/argus/webapi/routes/workspace_v2.py b/argus/webapi/routes/workspace_v2.py index b547e9ef2..1d0c78d4a 100644 --- a/argus/webapi/routes/workspace_v2.py +++ b/argus/webapi/routes/workspace_v2.py @@ -769,7 +769,7 @@ def rank(entry: dict[str, Any]) -> tuple[int, float]: return str(sorted(candidates, key=rank)[0]["path"]) if candidates else "" -def register_workspace_v2_routes(app, ctx: ServerContext, server_mod) -> None: +def register_workspace_v2_routes(app, ctx: ServerContext) -> None: dependencies = [Depends(ctx.require_auth)] @app.get("/api/v2/workspaces", dependencies=dependencies) diff --git a/argus/webapi/server.py b/argus/webapi/server.py index 36ddaf474..326ad9fa0 100644 --- a/argus/webapi/server.py +++ b/argus/webapi/server.py @@ -1,99 +1,36 @@ -"""``argus`` web/TUI backend API — thin FastAPI layer over the daemon. - -The 7×24 daemon is a file-based pub/sub: it appends events to -``/events.jsonl`` and reads commands from ``backlog.jsonl`` / -``inbox.jsonl``. Both new frontends — the Ink terminal UI (``frontend/tui/``) -and the React web UI (``frontend/web/``) — are **clients of this one API**, so -neither reimplements backend logic. - -Design rules (keep this layer dumb): -- Read-only project aggregation lives in :mod:`.project_state`; this module - re-exports its stable API for compatibility. -- Every endpoint DELEGATES to an existing ``argus`` function. This module - never parses event semantics or backlog schemas itself — it forwards dicts and - calls the reused helpers (``list_sessions``, ``read_daemon_status``, - ``role_activity``, ``resolve_all_roles``, ``_read_recent_jsonl_events``, - ``LifeMemory.backlog``). -- Defaults to a ``127.0.0.1`` bind — unlike ``tools/dashboard.py`` which binds - ``0.0.0.0`` with no auth. Expose to a LAN only via an explicit ``--web-host``. -- ``fastapi`` / ``uvicorn`` are the optional ``[web]`` extra; import them lazily - inside :func:`create_app` / :func:`serve` so importing this module never - hard-requires them. - -M0 scope: ``GET /api/projects``, ``GET /api/projects/{sid}/snapshot``, -``GET /api/projects/{sid}/events``, ``WS /api/projects/{sid}/stream``. -Command POSTs (task/nudge/daemon start-stop/config) land in M1. +"""FastAPI application, event streaming, and HTTP middleware for Web and TUI. + +Routes call their owning services directly. The daemon owns persistent work; +closing a browser or terminal connection does not stop it. """ -# NB: deliberately NO ``from __future__ import annotations`` here — the nested -# FastAPI route handlers in create_app() annotate params with the locally- -# imported ``WebSocket``/``Query`` types, and stringized annotations would make -# FastAPI fail to resolve them (it reads annotations against module globals, -# where the lazily-imported fastapi symbols do not live). Runtime ``X | None`` -# unions are fine on the required Python >=3.11. import asyncio import logging import os import queue import re -import threading # noqa: F401 - used via server.threading in tests/webapi/test_commands_m1.py import time from pathlib import Path from typing import Any, Callable from ..apps.cli._follow import ( _merge_recent_event_rows, - _read_recent_project_events, # noqa: F401 - used via server_mod._read_recent_project_events in webapi/routes/projects.py ) from ..core.event_catalog import EventType, canonical_event_type from ..core.metrics import ( http_route_template, - metrics_snapshot, # noqa: F401 - used via server_mod.metrics_snapshot in webapi/routes/meta.py record_metric, - render_prometheus, # noqa: F401 - used via server_mod.render_prometheus in webapi/routes/meta.py -) -from ..core.runtime_identity import ( - runtime_identity, # noqa: F401 - monkeypatched via server.runtime_identity; read by daemon_upgrade._srv() -) -from ..daemon.commands import ( - DaemonCommandReceipt, - daemon_command_execution_lock, # noqa: F401 - monkeypatched via server.daemon_command_execution_lock; read by daemon_upgrade._srv() - execute_daemon_command, # noqa: F401 - used via server_mod.execute_daemon_command in webapi/routes/daemon.py -) -from ..daemon.life_worker import ( - DaemonStatus, - _active_daemon_count, # noqa: F401 - monkeypatched via server._active_daemon_count; read by daemon_lifecycle._srv() - _active_workspace_owner, # noqa: F401 - monkeypatched via server._active_workspace_owner; read by daemon_lifecycle._srv() - _max_active_daemons, # noqa: F401 - monkeypatched via server._max_active_daemons; read by daemon_lifecycle._srv() - read_continuous_state, # noqa: F401 - used via server.read_continuous_state in tests/webapi/test_commands_m1.py - read_daemon_status, # also retained for daemon_lifecycle/daemon_upgrade compatibility - stop_daemon, # noqa: F401 - monkeypatched via server.stop_daemon; read by daemon_lifecycle/daemon_upgrade._srv() - write_continuous_config, # noqa: F401 - compatibility export -) -from ..daemon.life_worker import ( - spawn_detached_daemon_clean as spawn_detached_daemon, # noqa: F401 - monkeypatched via server.spawn_detached_daemon; read by daemon_lifecycle._srv() -) -from ..daemon.protocol import ( - daemon_protocol_compatibility, # noqa: F401 - monkeypatched via server.daemon_protocol_compatibility; read by daemon_upgrade._srv() - daemon_runtime_owned_by_current_source, # noqa: F401 - monkeypatched via server.daemon_runtime_owned_by_current_source; read by daemon_upgrade._srv() -) -from ..life.memory import ( - _read_jsonl_tail_history, # noqa: F401 - used via server_mod._read_jsonl_tail_history in webapi/routes/projects.py -) -from ..manager.front_door import ( - ManagerHandoffError, # noqa: F401 - used via server_mod.ManagerHandoffError in webapi/routes/{daemon,workitems}.py - ManagerHandoffSupersededError, # noqa: F401 - used via server_mod.ManagerHandoffSupersededError in webapi/routes/{daemon,workitems}.py ) -from . import artifacts, project_state -from .counterexample_dashboard import ( # noqa: F401 - route delegation export - build_counterexample_dashboard, +from ..daemon import life_worker as daemon_worker +from . import ( + daemon_lifecycle, + daemon_upgrade, + project_crud, + project_state, ) +from .daemon_services import DaemonServices from .protocol import build_api_meta, protocol_header -from .source_update import ( # noqa: F401 - used by routes/meta.py through server_mod - read_source_update_status, - start_source_update, -) log = logging.getLogger(__name__) @@ -135,64 +72,7 @@ def _uvicorn_log_config(uvicorn_module: Any) -> dict[str, Any]: return config -_global_root = project_state.resolve_global_root -_settled_spend = project_state.settled_spend -build_snapshot = project_state.build_snapshot -list_projects = project_state.list_projects -list_project_costs = project_state.list_project_costs -project_life_dir = project_state.project_life_dir -_artifact_metadata = artifacts.artifact_metadata -_manager_live_view_files = artifacts.manager_live_view_files -_project_git_diff = artifacts.project_git_diff -_project_workspace = artifacts.project_workspace -_resolved_project_artifact = artifacts.resolved_project_artifact -_safe_artifact_path = artifacts.safe_artifact_path -get_project_artifact = artifacts.get_project_artifact -list_project_artifacts = artifacts.list_project_artifacts - -__all__ = [ - "DaemonStatus", - "create_app", - "serve", - "project_life_dir", - "build_snapshot", - "list_projects", - "list_project_costs", - "enqueue_task", - "enqueue_nudge", - "answer_pending_question", - "start_project_daemon", - "stop_project_daemon", - "replace_project_daemon", - "list_running_daemons", - "update_project", - "delete_project", - "list_trashed_projects", - "restore_trashed_project", - "upgrade_project_daemon", - "schedule_project_daemon_upgrade", - "set_project_workdir", - "set_continuous", - "get_status", - "get_journal", - "add_project_note", - "abort_project_mission", - "dispose_backlog", - "stop_backlog_iteration", - "get_doctor", - "get_config", - "get_identity", - "get_transcript", - "get_backlog_item", - "set_operator_config", - "set_identity", - "run_skill_command", - "read_source_update_status", - "start_source_update", - "build_counterexample_dashboard", - "list_project_artifacts", - "get_project_artifact", -] +__all__ = ["create_app", "serve", "tail_events"] EVENT_FILE = "events.jsonl" _WEB_UI_DROPPED_EVENT_TYPES = frozenset( @@ -230,24 +110,8 @@ def _web_cache_control(path: str) -> str: return "" -def _command_response(receipt: DaemonCommandReceipt) -> dict[str, Any]: - result = dict(receipt.result) - if receipt.status in {"failed", "rejected"}: - result.setdefault("rc", 3) - result.setdefault("error", receipt.error) - result.update( - { - "command_id": receipt.command_id, - "command_status": receipt.status, - "command_revision": receipt.revision, - "command": receipt.to_jsonable(), - } - ) - return result - - # --------------------------------------------------------------------------- -# Pure helpers (no FastAPI import — unit-testable without the [web] extra) +# Event streaming helpers # --------------------------------------------------------------------------- @@ -308,75 +172,6 @@ def _iter_manager_stream_items( yield item -# --------------------------------------------------------------------------- -# Command helpers (write side) — all go through the SAME reused functions the -# CLI uses, so the flock CAS / atomic writes are shared. Never write the -# backlog/inbox files directly. Each returns None if the project is unknown. -# --------------------------------------------------------------------------- - -from . import ( - daemon_lifecycle, - daemon_upgrade, - manager_pending_question, - mission_items, - project_crud, -) -from .daemon_services import DaemonServices - -_SCHEDULED_DAEMON_UPGRADES = daemon_upgrade._SCHEDULED_DAEMON_UPGRADES -_SCHEDULED_DAEMON_UPGRADES_LOCK = daemon_upgrade._SCHEDULED_DAEMON_UPGRADES_LOCK -_worker_config_from_env = daemon_lifecycle._worker_config_from_env -list_running_daemons = daemon_lifecycle.list_running_daemons -_admission_required = daemon_lifecycle._admission_required -_clear_daemon_admission = daemon_lifecycle._clear_daemon_admission -start_project_daemon = daemon_lifecycle.start_project_daemon -_write_parked_state = daemon_lifecycle._write_parked_state -replace_project_daemon = daemon_lifecycle.replace_project_daemon -create_daemon = daemon_lifecycle.create_daemon -set_project_launch_cwd = daemon_lifecycle.set_project_launch_cwd -set_project_workdir = daemon_lifecycle.set_project_workdir -stop_project_daemon = daemon_lifecycle.stop_project_daemon -upgrade_project_daemon = daemon_upgrade.upgrade_project_daemon - -_daemon_upgrade_request_path = daemon_upgrade._daemon_upgrade_request_path -_read_daemon_upgrade_request = daemon_upgrade._read_daemon_upgrade_request -_write_daemon_upgrade_request = daemon_upgrade._write_daemon_upgrade_request -_upgrade_request_matches_current_source = daemon_upgrade._upgrade_request_matches_current_source -_record_daemon_upgrade_error = daemon_upgrade._record_daemon_upgrade_error -_complete_scheduled_daemon_upgrade = daemon_upgrade._complete_scheduled_daemon_upgrade -schedule_project_daemon_upgrade = daemon_upgrade.schedule_project_daemon_upgrade -reconcile_pending_daemon_upgrades = daemon_upgrade.reconcile_pending_daemon_upgrades - -update_project = project_crud.update_project -delete_project = project_crud.delete_project -list_trashed_projects = project_crud.list_trashed_projects -restore_trashed_project = project_crud.restore_trashed_project -set_continuous = project_crud.set_continuous - -_enqueue_task_unlocked = mission_items._enqueue_task_unlocked -enqueue_task = mission_items.enqueue_task -enqueue_task_command = mission_items.enqueue_task_command -enqueue_nudge = mission_items.enqueue_nudge -answer_pending_question = manager_pending_question.manager_answer_pending_question -resolve_operator_decision = manager_pending_question.manager_resolve_operator_decision -get_status = mission_items.get_status -get_journal = mission_items.get_journal -add_project_note = mission_items.add_project_note -get_backlog_item = mission_items.get_backlog_item -abort_project_mission = mission_items.abort_project_mission -dispose_backlog = mission_items.dispose_backlog -stop_backlog_iteration = mission_items.stop_backlog_iteration -_daemon_log_tail = mission_items._daemon_log_tail -get_doctor = mission_items.get_doctor -get_config = mission_items.get_config -get_identity = mission_items.get_identity -set_operator_config = mission_items.set_operator_config -set_budget_config = mission_items.set_budget_config -set_identity = mission_items.set_identity -run_skill_command = mission_items.run_skill_command -get_transcript = mission_items.get_transcript - - def _hides_inner_monologue() -> bool: """Whether the reasoning scratchpad stays out of the UI stream. @@ -458,11 +253,10 @@ def create_app( from starlette.middleware.gzip import GZipMiddleware from starlette.responses import JSONResponse - from . import server as server_mod from .index_cache import CacheWaitTimeout, QueryExecutor, QueryUnavailable token = auth_token if auth_token is not None else os.environ.get("ARGUS_SKILL_WEB_TOKEN") - primary_root = _global_root(global_root).expanduser().resolve() + primary_root = project_state.resolve_global_root(global_root).expanduser().resolve() roots: list[Path] = [primary_root] if session_roots is not None: candidates = [Path(root).expanduser() for root in session_roots] @@ -513,7 +307,7 @@ async def _add_protocol_headers(request, call_next): # noqa: ANN001 response = await call_next(request) except Exception: record_metric( - _global_root(global_root), + project_state.resolve_global_root(global_root), "web.request", labels={ "method": request.method, @@ -524,7 +318,7 @@ async def _add_protocol_headers(request, call_next): # noqa: ANN001 ) raise record_metric( - _global_root(global_root), + project_state.resolve_global_root(global_root), "web.request", labels={ "method": request.method, @@ -554,7 +348,7 @@ async def _add_protocol_headers(request, call_next): # noqa: ANN001 @app.on_event("startup") def _resume_pending_daemon_upgrades() -> None: - reconcile_pending_daemon_upgrades(roots) + daemon_upgrade.reconcile_pending_daemon_upgrades(roots) # Prime host-wide cost/usage projections before the first compact # snapshot. Otherwise a completed inline Manager call can momentarily # show project spend while global spend incorrectly appears empty. @@ -623,14 +417,14 @@ def _unregister_status_surface() -> None: token=token, roots=roots, api_meta=api_meta, - list_projects=list_projects, - list_project_costs=list_project_costs, - list_trashed_projects=list_trashed_projects, - project_life_dir=project_life_dir, + list_projects=project_state.list_projects, + list_project_costs=project_state.list_project_costs, + list_trashed_projects=project_crud.list_trashed_projects, + project_life_dir=project_state.project_life_dir, daemon_services=( daemon_services if daemon_services is not None else DaemonServices( - read_status=read_daemon_status, - start=start_project_daemon, + read_status=daemon_worker.read_daemon_status, + start=daemon_lifecycle.start_project_daemon, ) ), query_executor=QueryExecutor(query_limits), @@ -650,20 +444,18 @@ async def _shutdown_query_workers() -> None: # config/diagnostics (meta, metrics, per-project config/identity/doctor, # operator config/budget/identity/reset/skills). Registrars share this # app's auth/root helpers and narrow daemon services through ``ctx``. - # Project/work-item operations call their owning modules directly. The - # remaining legacy services still receive the server compatibility facade. - register_project_routes(app, ctx, server_mod) - register_workitem_routes(app, ctx, server_mod) - register_counterexample_routes(app, ctx, server_mod) - register_daemon_routes(app, ctx, server_mod) - register_artifact_routes(app, ctx, server_mod) + register_project_routes(app, ctx) + register_workitem_routes(app, ctx) + register_counterexample_routes(app, ctx) + register_daemon_routes(app, ctx) + register_artifact_routes(app, ctx) from .routes.reader_foundation import register_reader_foundation_routes register_reader_foundation_routes(app, ctx) - register_manager_routes(app, ctx, server_mod) - register_meta_routes(app, ctx, server_mod) + register_manager_routes(app, ctx) + register_meta_routes(app, ctx) register_advisor_routes(app, ctx) - register_workspace_v2_routes(app, ctx, server_mod) + register_workspace_v2_routes(app, ctx) from .routes.map_datasets import register_map_dataset_routes register_map_dataset_routes(app, ctx) diff --git a/docs/runtime-maintainability.md b/docs/runtime-maintainability.md index d5d424e8c..ae765a2e7 100644 --- a/docs/runtime-maintainability.md +++ b/docs/runtime-maintainability.md @@ -22,6 +22,13 @@ CLI adapter 在构造时直接导入仓库内的 `AgentCliRunner`,使用真实 使用的兼容入口。任务完成时,`_CostTrackingSink.completion_usage()` 一次读取账本, 同时生成总量与角色明细;完成事件复用该结果,避免分别读取字段时混入后来的记账。 +Web API 路由只接收 `app` 和 `ServerContext`,直接调用 `daemon_lifecycle`、 +`daemon_upgrade`、`mission_items`、`project_crud`、`project_state` 等实现模块。 +`server.py` 保留 app 构建、运行入口和事件流;原先从 server 转出的业务函数改从 +所属模块导入。包级 `argus.webapi.build_snapshot` 和 `project_life_dir` 入口保留。 +命令 ID、版本校验和收据通过 daemon 路由内的同一个执行入口处理;多根目录的项目 +列表与费用列表共用目录归属规则,缓存和 daemon 服务仍由各 app 独立持有。 + ## 阅读入口 ```mermaid diff --git a/tests/core/test_usage.py b/tests/core/test_usage.py index d8e3dc63b..950eed604 100644 --- a/tests/core/test_usage.py +++ b/tests/core/test_usage.py @@ -21,7 +21,7 @@ ) from argus.life.supervisor import global_daily_spend from argus.life.supervisor._cost import _CostTrackingSink -from argus.webapi.server import _settled_spend +from argus.webapi.project_state import settled_spend as _settled_spend class _Sink: diff --git a/tests/test_architecture_invariants.py b/tests/test_architecture_invariants.py index 873ef1463..b048ebc07 100644 --- a/tests/test_architecture_invariants.py +++ b/tests/test_architecture_invariants.py @@ -1940,7 +1940,7 @@ def _ratchet_report(name: str, baseline: int, per_file: dict[str, int], constant # 23 = 22 + verticals/store.py: the Vertical Store's ``used_by`` scan walks the # session-state collection through the one canonical accessor rather than a # seventh spelling; it drops back when ``core.paths`` gains ``projects_root``. - "session_states_root": 23, + "session_states_root": 22, "session_state_root": 31, } diff --git a/tests/trial/test_journey_journal.py b/tests/trial/test_journey_journal.py index 72f31219b..3692ae69c 100644 --- a/tests/trial/test_journey_journal.py +++ b/tests/trial/test_journey_journal.py @@ -11,6 +11,7 @@ from argus.trial.analytics import Analytics, AnalyticsError from argus.trial.interaction_capture import Capture from argus.trial.journey_journal import Journal +from argus.webapi import daemon_lifecycle @pytest.fixture @@ -709,7 +710,7 @@ def test_real_message_http_contract_links_input_response_and_runtime(setup, monk task = BacklogItem.new(title="Synthetic task", objective="Make a plot", item_id="task-http-contract") result = {"kind": "task", "reply": None, "item": _item_to_dict(task, "Synthetic task")} monkeypatch.setattr(manager_bridge, "manager_message", lambda *args, **kwargs: dict(result)) - monkeypatch.setattr(server, "start_project_daemon", lambda *args, **kwargs: {"alive": True}) + monkeypatch.setattr(daemon_lifecycle, 'start_project_daemon', lambda *args, **kwargs: {"alive": True}) monkeypatch.setattr(manager_pending_question, "record_task_dispatch_ack", lambda *args, **kwargs: None) route = "/api/projects/s-project/message" + ("/stream" if stream else "") captured = Capture(analytics, "tenant-one", "s-project", route, {"text": "Make a plot"}) diff --git a/tests/webapi/test_abort_control.py b/tests/webapi/test_abort_control.py index 2522a5647..921e2f5d9 100644 --- a/tests/webapi/test_abort_control.py +++ b/tests/webapi/test_abort_control.py @@ -1,8 +1,8 @@ from __future__ import annotations from argus.life.memory import BacklogItem, LifeMemory +from argus.webapi.mission_items import abort_project_mission from argus.webapi.protocol import API_CAPABILITIES -from argus.webapi.server import abort_project_mission def test_abort_endpoint_helper_targets_running_item(tmp_path) -> None: diff --git a/tests/webapi/test_cancelled_manager_reply.py b/tests/webapi/test_cancelled_manager_reply.py index 670bb3f13..ada4ca4bd 100644 --- a/tests/webapi/test_cancelled_manager_reply.py +++ b/tests/webapi/test_cancelled_manager_reply.py @@ -11,6 +11,7 @@ from argus.adapters.agent_cli_backend import AgentCliBackend from argus.core.session import SessionMeta, write_session_meta from argus.core.transcript import append_turn, read_turns +from argus.daemon import life_worker as daemon_worker from argus.life import answer_learning from argus.manager import config_intent, front_door from argus.webapi import server @@ -58,7 +59,7 @@ def triage(_mem, body, state, *, on_fragment, **_kwargs): monkeypatch.setattr(config_intent, "_front_door_classify", lambda *_args, **_kwargs: (None, None, "simple")) monkeypatch.setattr(front_door, "manager_triage", triage) services = DaemonServices( - read_status=server.read_daemon_status, + read_status=daemon_worker.read_daemon_status, start=lambda *_args, **_kwargs: starts.append(True) or {"rc": 0}, ) path = f"/api/projects/{sid}/message" + ("/stream" if streaming else "") diff --git a/tests/webapi/test_commands_m1.py b/tests/webapi/test_commands_m1.py index a7795fee0..ca5323cf0 100644 --- a/tests/webapi/test_commands_m1.py +++ b/tests/webapi/test_commands_m1.py @@ -15,7 +15,11 @@ import pytest +from argus.core import runtime_identity as runtime_identity_module from argus.core.session import SessionMeta, touch_session, write_session_meta +from argus.daemon import commands as daemon_commands +from argus.daemon import life_worker as daemon_worker +from argus.daemon import protocol as daemon_protocol from argus.daemon.state import write_continuous_config from argus.life.memory import BacklogItem, LifeMemory from argus.life.supervisor import LifeSupervisor, LifeSupervisorConfig @@ -27,20 +31,25 @@ from argus.skills.vertical_select import persist_vertical from argus.webapi import ( daemon_lifecycle, + daemon_upgrade, manager_dispatch, manager_state, + mission_items, + project_crud, project_state, server, ) from argus.webapi.daemon_services import DaemonServices pytest.importorskip("fastapi") +import threading + from fastapi.testclient import TestClient # noqa: E402 def _daemon_services(*, alive: bool = False) -> DaemonServices: return DaemonServices( - read_status=lambda path: server.DaemonStatus( + read_status=lambda path: daemon_worker.DaemonStatus( alive=alive, pid=123 if alive else None, started_at_iso=None, @@ -189,7 +198,7 @@ def _kind_for(vertical): lambda chat_state, mem: SimpleNamespace(manager=_Manager()), ) - response = server.enqueue_task( + response = mission_items.enqueue_task( sid, "verify scope without changing the campaign", global_root=root, @@ -283,7 +292,7 @@ def fake_spawn(config, *, quiet=False): spawned["life_dir"] = config.life_dir return 0 - monkeypatch.setattr(server, "spawn_detached_daemon", fake_spawn) + monkeypatch.setattr(daemon_worker, 'spawn_detached_daemon_clean', fake_spawn) client = TestClient(server.create_app(global_root=root)) r = client.post(f"/api/projects/{sid}/tasks", json={"text": "run it"}) # autostart default assert r.status_code == 200 @@ -313,7 +322,7 @@ def test_start_project_daemon_returns_replacement_candidates_at_cap( def fake_status(path): path = Path(path) alive = path == running - return server.DaemonStatus( + return daemon_worker.DaemonStatus( alive=alive, pid=123 if alive else None, started_at_iso=None, @@ -322,17 +331,17 @@ def fake_status(path): pid_path=path / "daemon.pid", ) - monkeypatch.setattr(server, "read_daemon_status", fake_status) + monkeypatch.setattr(daemon_worker, 'read_daemon_status', fake_status) monkeypatch.setattr(project_state, "read_daemon_status", fake_status) - monkeypatch.setattr(server, "_max_active_daemons", lambda config: 1) - monkeypatch.setattr(server, "_active_daemon_count", lambda config: 1) + monkeypatch.setattr(daemon_lifecycle, '_max_active_daemons', lambda config: 1) + monkeypatch.setattr(daemon_lifecycle, '_active_daemon_count', lambda config: 1) monkeypatch.setattr( - server, - "spawn_detached_daemon", + daemon_worker, + 'spawn_detached_daemon_clean', lambda config, quiet=True: spawned.append(config.life_dir) or 0, ) - result = server.start_project_daemon("s-target001", global_root=tmp_path) + result = daemon_lifecycle.start_project_daemon("s-target001", global_root=tmp_path) assert result is not None and result["rc"] == 2 assert result["admission_required"] is True assert result["limit"] == 1 @@ -343,7 +352,7 @@ def fake_status(path): assert target.exists() persisted = json.loads((target / "daemon.admission.json").read_text()) assert persisted["running_daemons"][0]["id"] == "s-running01" - snapshot = server.build_snapshot("s-target001", global_root=tmp_path) + snapshot = project_state.build_snapshot("s-target001", global_root=tmp_path) assert snapshot is not None assert snapshot["daemon_admission"]["requested_at"] == persisted["requested_at"] @@ -359,7 +368,7 @@ def test_lazy_task_start_reclaims_oldest_safe_idle_daemon( def fake_status(path): path = Path(path) alive = path == victim - return server.DaemonStatus( + return daemon_worker.DaemonStatus( alive=alive, pid=44 if alive else None, started_at_iso=None, @@ -368,12 +377,12 @@ def fake_status(path): pid_path=path / "daemon.pid", ) - monkeypatch.setattr(server, "read_daemon_status", fake_status) - monkeypatch.setattr(server, "_max_active_daemons", lambda config: 1) - monkeypatch.setattr(server, "_active_daemon_count", lambda config: 1) + monkeypatch.setattr(daemon_worker, 'read_daemon_status', fake_status) + monkeypatch.setattr(daemon_lifecycle, '_max_active_daemons', lambda config: 1) + monkeypatch.setattr(daemon_lifecycle, '_active_daemon_count', lambda config: 1) monkeypatch.setattr( - server, - "list_running_daemons", + daemon_lifecycle, + 'list_running_daemons', lambda **kwargs: [ { "id": "s-idle0001", @@ -385,8 +394,8 @@ def fake_status(path): ], ) monkeypatch.setattr( - server, - "replace_project_daemon", + daemon_lifecycle, + 'replace_project_daemon', lambda sid, victim_sid, **kwargs: ( replaced.update( sid=sid, @@ -396,7 +405,7 @@ def fake_status(path): ), ) - result = server.start_project_daemon( + result = daemon_lifecycle.start_project_daemon( "s-target001", global_root=tmp_path, reclaim_idle=True, @@ -414,14 +423,14 @@ def test_replace_project_daemon_parks_state_then_starts_target( ) -> None: target = _make_project(tmp_path, "s-target001") victim = _make_project(tmp_path, "s-victim001") - server.enqueue_task("s-victim001", "unfinished work", global_root=tmp_path) + mission_items.enqueue_task("s-victim001", "unfinished work", global_root=tmp_path) running = {"s-victim001"} spawned = [] def fake_status(path): path = Path(path) alive = path.name in running - return server.DaemonStatus( + return daemon_worker.DaemonStatus( alive=alive, pid=321 if alive else None, started_at_iso=None, @@ -440,12 +449,12 @@ def fake_spawn(config, *, quiet=False): spawned.append(config.life_dir) return 0 - monkeypatch.setattr(server, "read_daemon_status", fake_status) + monkeypatch.setattr(daemon_worker, 'read_daemon_status', fake_status) monkeypatch.setattr(project_state, "read_daemon_status", fake_status) - monkeypatch.setattr(server, "_max_active_daemons", lambda config: 1) - monkeypatch.setattr(server, "_active_daemon_count", lambda config: len(running)) - monkeypatch.setattr(server, "stop_daemon", fake_stop) - monkeypatch.setattr(server, "spawn_detached_daemon", fake_spawn) + monkeypatch.setattr(daemon_lifecycle, '_max_active_daemons', lambda config: 1) + monkeypatch.setattr(daemon_lifecycle, '_active_daemon_count', lambda config: len(running)) + monkeypatch.setattr(daemon_worker, 'stop_daemon', fake_stop) + monkeypatch.setattr(daemon_worker, 'spawn_detached_daemon_clean', fake_spawn) (target / "daemon.admission.json").write_text( json.dumps( { @@ -461,7 +470,7 @@ def fake_spawn(config, *, quiet=False): ) ) - result = server.replace_project_daemon( + result = daemon_lifecycle.replace_project_daemon( "s-target001", "s-victim001", global_root=tmp_path, @@ -586,7 +595,7 @@ def fake_spawn(config, *, quiet=False): spawned["resume_continuous"] = config.resume_continuous return 0 - monkeypatch.setattr(server, "spawn_detached_daemon", fake_spawn) + monkeypatch.setattr(daemon_worker, 'spawn_detached_daemon_clean', fake_spawn) client = TestClient(server.create_app(global_root=root)) r = client.post( f"/api/projects/{sid}/continuous", json={"enabled": True, "objective": "keep improving X"} @@ -610,7 +619,7 @@ def test_set_continuous_persists_only_manager_execution_handoff( _install_manager(monkeypatch, lambda text: "study MRAM continuously") assert ( - server.set_continuous( + project_crud.set_continuous( sid, enabled=True, objective=raw, @@ -629,7 +638,7 @@ def test_disable_continuous_is_immediate_and_ignores_submitted_objective( monkeypatch, ) -> None: root, sid, life = ctx - server.write_continuous_config( + daemon_worker.write_continuous_config( life, enabled=True, objective="clean current objective", @@ -648,7 +657,7 @@ def unexpected_handoff(*args, **kwargs): ) assert ( - server.set_continuous( + project_crud.set_continuous( sid, enabled=False, objective="raw stale UI objective; Manager owns the sidebar", @@ -658,7 +667,7 @@ def unexpected_handoff(*args, **kwargs): ) assert bridge_state["config"]["continuous"] is False assert bridge_state["continuous_objective"] == "" - state = server.read_continuous_state(life) + state = daemon_worker.read_continuous_state(life) assert state.enabled is False assert state.objective == "clean current objective" @@ -677,7 +686,7 @@ def test_disable_continuous_surfaces_persistence_failure( ) with pytest.raises(ManagerHandoffError, match="could not be persisted"): - server.set_continuous( + project_crud.set_continuous( sid, enabled=False, global_root=root, @@ -693,7 +702,7 @@ def test_enable_continuous_reprocesses_stored_objective( ) -> None: root, sid, life = ctx raw = "legacy objective; Manager owns the sidebar" - server.write_continuous_config( + daemon_worker.write_continuous_config( life, enabled=False, objective=raw, @@ -708,7 +717,7 @@ def clean_handoff(text): _install_manager(monkeypatch, clean_handoff) assert ( - server.set_continuous( + project_crud.set_continuous( sid, enabled=True, objective="", @@ -716,7 +725,7 @@ def clean_handoff(text): ) is True ) - state = server.read_continuous_state(life) + state = daemon_worker.read_continuous_state(life) assert seen["text"] == raw assert state.enabled is True assert state.objective == "clean legacy objective" @@ -740,7 +749,7 @@ def test_enable_continuous_does_not_overwrite_newer_same_value_stop( monkeypatch, ) -> None: root, sid, life = ctx - server.write_continuous_config( + daemon_worker.write_continuous_config( life, enabled=False, objective="paused objective", @@ -750,7 +759,7 @@ def test_enable_continuous_does_not_overwrite_newer_same_value_stop( class _Manager: def decide_vertical(self, text, **kwargs): - server.set_continuous( + project_crud.set_continuous( sid, enabled=False, objective=text, @@ -769,14 +778,14 @@ def commit_vertical_decision(self, text, decision, **kwargs): ) with pytest.raises(ManagerHandoffSupersededError): - server.set_continuous( + project_crud.set_continuous( sid, enabled=True, objective="new objective", global_root=root, ) - state = server.read_continuous_state(life) + state = daemon_worker.read_continuous_state(life) assert state.enabled is False assert state.objective == "paused objective" assert commits == [] @@ -819,7 +828,7 @@ def fake_spawn(config, *, quiet=False): assert memory.backlog.all()[0].attempt == 1 return 0 - monkeypatch.setattr(server, "spawn_detached_daemon", fake_spawn) + monkeypatch.setattr(daemon_worker, 'spawn_detached_daemon_clean', fake_spawn) client = TestClient(server.create_app(global_root=root)) r = client.post(f"/api/projects/{sid}/daemon/start") assert r.status_code == 200 and r.json()["rc"] == 0 @@ -833,7 +842,7 @@ def test_daemon_start_resumes_provider_fence_without_respawning_live_worker( ) -> None: root, sid, life = ctx memory, item = fenced_backlog - status = server.DaemonStatus( + status = daemon_worker.DaemonStatus( alive=True, pid=321, started_at_iso=None, @@ -841,12 +850,12 @@ def test_daemon_start_resumes_provider_fence_without_respawning_live_worker( life_dir=life, pid_path=life / "daemon.pid", ) - monkeypatch.setattr(server, "read_daemon_status", lambda _path: status) + monkeypatch.setattr(daemon_worker, 'read_daemon_status', lambda _path: status) def no_spawn(*_args, **_kwargs): pytest.fail("an already-running worker must not be spawned again") - monkeypatch.setattr(server, "spawn_detached_daemon", no_spawn) + monkeypatch.setattr(daemon_worker, 'spawn_detached_daemon_clean', no_spawn) client = TestClient(server.create_app(global_root=root)) result = client.post(f"/api/projects/{sid}/daemon/start").json() @@ -871,7 +880,7 @@ def start(*args, **_kwargs): assert memory.backlog.all()[0].status == "paused_provider_fence" return {"rc": 0, "already_alive": True} - monkeypatch.setattr(server, f"{operation}_project_daemon", start) + monkeypatch.setattr(daemon_lifecycle, f"{operation}_project_daemon", start) client = TestClient(server.create_app(global_root=root)) url = f"/api/projects/{sid}/daemon/{operation}" body = {"command_id": "resume-fence", "expected_revision": 0} @@ -915,7 +924,7 @@ def test_failed_daemon_resume_preserves_provider_fence( root, sid, _life = ctx memory, _item = fenced_backlog monkeypatch.setattr( - server, f"{operation}_project_daemon", lambda *_args, **_kwargs: result, + daemon_lifecycle, f"{operation}_project_daemon", lambda *_args, **_kwargs: result, ) client = TestClient(server.create_app(global_root=root)) body = {"command_id": "failed-resume"} @@ -936,10 +945,10 @@ def test_automatic_continuous_restart_preserves_provider_fence( root, sid, _life = ctx memory, _item = fenced_backlog monkeypatch.setattr( - server, "spawn_detached_daemon", lambda *_args, **_kwargs: 0, + daemon_worker, 'spawn_detached_daemon_clean', lambda *_args, **_kwargs: 0, ) - result = server.start_project_daemon( + result = daemon_lifecycle.start_project_daemon( sid, global_root=root, resume_continuous=True, ) @@ -954,7 +963,7 @@ def test_daemon_replace_without_resume_preserves_provider_fence( root, sid, _life = ctx memory, _item = fenced_backlog monkeypatch.setattr( - server, "replace_project_daemon", lambda *_args, **_kwargs: {"rc": 0}, + daemon_lifecycle, 'replace_project_daemon', lambda *_args, **_kwargs: {"rc": 0}, ) client = TestClient(server.create_app(global_root=root)) @@ -976,7 +985,7 @@ def fail_spawn(_config, *, quiet=False): assert quiet is True raise RuntimeError("ModuleNotFoundError: No module named 'uvicorn'") - monkeypatch.setattr(server, "spawn_detached_daemon", fail_spawn) + monkeypatch.setattr(daemon_worker, 'spawn_detached_daemon_clean', fail_spawn) client = TestClient(server.create_app(global_root=root)) response = client.post(f"/api/projects/{sid}/daemon/start") @@ -1002,7 +1011,7 @@ def fake_spawn(config, *, quiet=False): config.last_spawn_error = diagnostic return 1 - monkeypatch.setattr(server, "spawn_detached_daemon", fake_spawn) + monkeypatch.setattr(daemon_worker, 'spawn_detached_daemon_clean', fake_spawn) client = TestClient(server.create_app(global_root=root)) response = client.post(f"/api/projects/{sid}/daemon/start") @@ -1033,9 +1042,9 @@ def fake_spawn(config, *, quiet=False): spawned["quiet"] = quiet return 0 - monkeypatch.setattr(server, "spawn_detached_daemon", fake_spawn) + monkeypatch.setattr(daemon_worker, 'spawn_detached_daemon_clean', fake_spawn) - result = server.start_project_daemon( + result = daemon_lifecycle.start_project_daemon( sid, global_root=root, resume_continuous=resume_continuous, @@ -1063,10 +1072,10 @@ def fake_spawn(config, *, quiet=False): config.last_spawn_error = "" return 0 - monkeypatch.setattr(server, "spawn_detached_daemon", fake_spawn) + monkeypatch.setattr(daemon_worker, 'spawn_detached_daemon_clean', fake_spawn) monkeypatch.setattr(daemon_lifecycle, "_running_on_windows", lambda: True) - result = server.start_project_daemon(sid, global_root=root) + result = daemon_lifecycle.start_project_daemon(sid, global_root=root) assert result is not None and result["rc"] == 0 assert result["startup_retried"] is True @@ -1087,10 +1096,10 @@ def fake_spawn(config, *, quiet=False): config.last_spawn_error = "ModuleNotFoundError: No module named argus" return 1 - monkeypatch.setattr(server, "spawn_detached_daemon", fake_spawn) + monkeypatch.setattr(daemon_worker, 'spawn_detached_daemon_clean', fake_spawn) monkeypatch.setattr(daemon_lifecycle, "_running_on_windows", lambda: True) - result = server.start_project_daemon(sid, global_root=root) + result = daemon_lifecycle.start_project_daemon(sid, global_root=root) assert result is not None and result["rc"] == 1 assert attempts == 1 @@ -1102,7 +1111,7 @@ def test_daemon_start_accepts_runtime_published_after_transient_launcher_failure ) -> None: root, sid, _life = ctx attempts = 0 - original_status = server.read_daemon_status + original_status = daemon_worker.read_daemon_status def fake_spawn(config, *, quiet=False): nonlocal attempts @@ -1120,11 +1129,11 @@ def fake_status(path): return status return dataclasses.replace(status, alive=True, pid=4242) - monkeypatch.setattr(server, "spawn_detached_daemon", fake_spawn) - monkeypatch.setattr(server, "read_daemon_status", fake_status) + monkeypatch.setattr(daemon_worker, 'spawn_detached_daemon_clean', fake_spawn) + monkeypatch.setattr(daemon_worker, 'read_daemon_status', fake_status) monkeypatch.setattr(daemon_lifecycle, "_running_on_windows", lambda: True) - result = server.start_project_daemon(sid, global_root=root) + result = daemon_lifecycle.start_project_daemon(sid, global_root=root) assert result is not None and result["rc"] == 0 assert result["startup_retried"] is True @@ -1152,16 +1161,16 @@ def fake_spawn(config, *, quiet=False): spawned["open_ended"] = config.continuous_open_ended return 0 - monkeypatch.setattr(server, "spawn_detached_daemon", fake_spawn) + monkeypatch.setattr(daemon_worker, 'spawn_detached_daemon_clean', fake_spawn) - result = server.start_project_daemon( + result = daemon_lifecycle.start_project_daemon( sid, global_root=root, resume_continuous=True, ) assert result is not None and result["rc"] == 0 - state = server.read_continuous_state(life) + state = daemon_worker.read_continuous_state(life) assert state.enabled is True assert state.objective == "continue the proof campaign" assert state.done_reason == "" @@ -1190,16 +1199,16 @@ def fake_spawn(config, *, quiet=False): spawned["resume_continuous"] = config.resume_continuous return 0 - monkeypatch.setattr(server, "spawn_detached_daemon", fake_spawn) + monkeypatch.setattr(daemon_worker, 'spawn_detached_daemon_clean', fake_spawn) - result = server.start_project_daemon( + result = daemon_lifecycle.start_project_daemon( sid, global_root=root, resume_continuous=True, ) assert result is not None and result["rc"] == 0 - state = server.read_continuous_state(life) + state = daemon_worker.read_continuous_state(life) assert state.enabled is False assert state.done_reason == "planner declared project done" assert spawned == {"objective": "", "resume_continuous": False} @@ -1214,7 +1223,7 @@ def fake_stop(life_dir=None, *, timeout=10.0, drain=False, drain_timeout=1800.0, seen["drain"] = drain return 0 - monkeypatch.setattr(server, "stop_daemon", fake_stop) + monkeypatch.setattr(daemon_worker, 'stop_daemon', fake_stop) client = TestClient(server.create_app(global_root=root)) r = client.post(f"/api/projects/{sid}/daemon/stop", json={"drain": True}) assert r.status_code == 200 and r.json()["rc"] == 0 @@ -1225,8 +1234,8 @@ def test_daemon_upgrade_restarts_from_current_web_release(ctx, monkeypatch) -> N root, sid, _life = ctx calls = [] monkeypatch.setattr( - server, - "upgrade_project_daemon", + daemon_upgrade, + 'upgrade_project_daemon', lambda project_id, **kwargs: calls.append(project_id) or {"rc": 0, "upgraded": True}, ) client = TestClient(server.create_app(global_root=root)) @@ -1245,8 +1254,8 @@ def test_daemon_upgrade_schedule_returns_before_boundary_drain( root, sid, _life = ctx calls = [] monkeypatch.setattr( - server, - "schedule_project_daemon_upgrade", + daemon_upgrade, + 'schedule_project_daemon_upgrade', lambda project_id, **kwargs: ( calls.append((project_id, kwargs)) or {"rc": 0, "scheduled": True, "reason": "release mismatch"} @@ -1266,7 +1275,7 @@ def test_schedule_daemon_upgrade_requests_nonblocking_boundary_drain( monkeypatch, ) -> None: root, sid, life = ctx - status = server.DaemonStatus( + status = daemon_worker.DaemonStatus( alive=True, pid=321, started_at_iso=None, @@ -1274,39 +1283,39 @@ def test_schedule_daemon_upgrade_requests_nonblocking_boundary_drain( life_dir=life, pid_path=life / "daemon.pid", ) - monkeypatch.setattr(server, "read_daemon_status", lambda path: status) + monkeypatch.setattr(daemon_worker, 'read_daemon_status', lambda path: status) monkeypatch.setattr( - server, - "daemon_protocol_compatibility", + daemon_protocol, + 'daemon_protocol_compatibility', lambda value: (False, "release mismatch"), ) monkeypatch.setattr( - server, - "daemon_runtime_owned_by_current_source", + daemon_protocol, + 'daemon_runtime_owned_by_current_source', lambda value: True, ) source = root / "checkout" source.mkdir() monkeypatch.setattr( - server, - "runtime_identity", + runtime_identity_module, + 'runtime_identity', lambda: {"source_root": str(source)}, ) monkeypatch.setattr( - server, - "read_continuous_state", + daemon_worker, + 'read_continuous_state', lambda path: SimpleNamespace(enabled=True, objective="keep going"), ) stops = [] monkeypatch.setattr( - server, - "stop_daemon", + daemon_worker, + 'stop_daemon', lambda path, **kwargs: stops.append((path, kwargs)) or 2, ) starts = [] monkeypatch.setattr( - server, - "start_project_daemon", + daemon_lifecycle, + 'start_project_daemon', lambda project_id, **kwargs: starts.append((project_id, kwargs)) or {"rc": 0}, ) locks = [] @@ -1316,7 +1325,7 @@ def execution_lock(path, *, blocking=True): locks.append((path, blocking)) yield True - monkeypatch.setattr(server, "daemon_command_execution_lock", execution_lock) + monkeypatch.setattr(daemon_commands, 'daemon_command_execution_lock', execution_lock) class ImmediateThread: def __init__(self, *, target, name, daemon): @@ -1336,11 +1345,11 @@ def __init__(self, delay, target): def start(self): timers.append((self.delay, self.daemon)) - monkeypatch.setattr(server.threading, "Thread", ImmediateThread) - monkeypatch.setattr(server.threading, "Timer", DeferredTimer) - server._SCHEDULED_DAEMON_UPGRADES.clear() + monkeypatch.setattr(threading, "Thread", ImmediateThread) + monkeypatch.setattr(threading, "Timer", DeferredTimer) + daemon_upgrade._SCHEDULED_DAEMON_UPGRADES.clear() - result = server.schedule_project_daemon_upgrade(sid, global_root=root) + result = daemon_upgrade.schedule_project_daemon_upgrade(sid, global_root=root) assert result == {"rc": 0, "scheduled": True, "reason": "release mismatch"} assert stops == [ @@ -1367,8 +1376,8 @@ def test_pending_daemon_upgrade_survives_webapi_restart( root, sid, life = ctx source = root / "checkout" source.mkdir() - monkeypatch.setattr(server, "runtime_identity", lambda: {"source_root": str(source)}) - server._write_daemon_upgrade_request( + monkeypatch.setattr(runtime_identity_module, 'runtime_identity', lambda: {"source_root": str(source)}) + daemon_upgrade._write_daemon_upgrade_request( life, { "schema_version": 1, @@ -1382,9 +1391,9 @@ def test_pending_daemon_upgrade_survives_webapi_restart( }, ) monkeypatch.setattr( - server, - "read_daemon_status", - lambda path: server.DaemonStatus( + daemon_worker, + 'read_daemon_status', + lambda path: daemon_worker.DaemonStatus( alive=False, pid=None, started_at_iso=None, @@ -1394,18 +1403,18 @@ def test_pending_daemon_upgrade_survives_webapi_restart( ) writes = [] monkeypatch.setattr( - server, - "write_continuous_config", + daemon_worker, + 'write_continuous_config', lambda path, **kwargs: writes.append((path, kwargs)), ) starts = [] monkeypatch.setattr( - server, - "start_project_daemon", + daemon_lifecycle, + 'start_project_daemon', lambda project_id, **kwargs: starts.append((project_id, kwargs)) or {"rc": 0}, ) - result = server._complete_scheduled_daemon_upgrade( + result = daemon_upgrade._complete_scheduled_daemon_upgrade( sid, life_dir=life, global_root=root, @@ -1434,24 +1443,24 @@ def test_explicit_stop_cancels_scheduled_restart_without_resurrection( "reason": "release mismatch", "requested_at": 1, } - server._write_daemon_upgrade_request(life, request) - monkeypatch.setattr(server, "runtime_identity", lambda: {"source_root": str(source)}) - status = server.DaemonStatus( + daemon_upgrade._write_daemon_upgrade_request(life, request) + monkeypatch.setattr(runtime_identity_module, 'runtime_identity', lambda: {"source_root": str(source)}) + status = daemon_worker.DaemonStatus( alive=True, pid=321, started_at_iso=None, uptime_seconds=1.0, life_dir=life, ) - monkeypatch.setattr(server, "read_daemon_status", lambda path: status) + monkeypatch.setattr(daemon_worker, 'read_daemon_status', lambda path: status) monkeypatch.setattr( - server, - "daemon_protocol_compatibility", + daemon_protocol, + 'daemon_protocol_compatibility', lambda value: (False, "release mismatch"), ) monkeypatch.setattr( - server, - "daemon_runtime_owned_by_current_source", + daemon_protocol, + 'daemon_runtime_owned_by_current_source', lambda value: True, ) @@ -1459,15 +1468,15 @@ def explicit_stop_wins(path, **kwargs): (life / server.project_state.DAEMON_UPGRADE_REQUEST_FILE).unlink() return 0 - monkeypatch.setattr(server, "stop_daemon", explicit_stop_wins) + monkeypatch.setattr(daemon_worker, 'stop_daemon', explicit_stop_wins) starts = [] monkeypatch.setattr( - server, - "start_project_daemon", + daemon_lifecycle, + 'start_project_daemon', lambda *args, **kwargs: starts.append((args, kwargs)) or {"rc": 0}, ) - result = server._complete_scheduled_daemon_upgrade( + result = daemon_upgrade._complete_scheduled_daemon_upgrade( sid, life_dir=life, global_root=root, @@ -1493,14 +1502,14 @@ def test_webapi_startup_resumes_pending_daemon_upgrades( ) scheduled = [] monkeypatch.setattr( - server, - "schedule_project_daemon_upgrade", + daemon_upgrade, + 'schedule_project_daemon_upgrade', lambda project_id, **kwargs: ( scheduled.append((project_id, kwargs)) or {"rc": 0, "scheduled": True} ), ) - assert server.reconcile_pending_daemon_upgrades([root]) == [sid] + assert daemon_upgrade.reconcile_pending_daemon_upgrades([root]) == [sid] assert scheduled == [(sid, {"global_root": root})] @@ -1511,8 +1520,8 @@ def test_webapi_startup_hook_runs_daemon_upgrade_reconciliation( root, _sid, _life = ctx calls = [] monkeypatch.setattr( - server, - "reconcile_pending_daemon_upgrades", + daemon_upgrade, + 'reconcile_pending_daemon_upgrades', lambda roots: calls.append(roots) or [], ) @@ -1529,28 +1538,28 @@ def test_schedule_daemon_upgrade_retries_after_thread_start_failure( root, sid, life = ctx source = root / "checkout" source.mkdir() - status = server.DaemonStatus( + status = daemon_worker.DaemonStatus( alive=True, pid=321, started_at_iso=None, uptime_seconds=1.0, life_dir=life, ) - monkeypatch.setattr(server, "runtime_identity", lambda: {"source_root": str(source)}) - monkeypatch.setattr(server, "read_daemon_status", lambda path: status) + monkeypatch.setattr(runtime_identity_module, 'runtime_identity', lambda: {"source_root": str(source)}) + monkeypatch.setattr(daemon_worker, 'read_daemon_status', lambda path: status) monkeypatch.setattr( - server, - "daemon_protocol_compatibility", + daemon_protocol, + 'daemon_protocol_compatibility', lambda value: (False, "release mismatch"), ) monkeypatch.setattr( - server, - "daemon_runtime_owned_by_current_source", + daemon_protocol, + 'daemon_runtime_owned_by_current_source', lambda value: True, ) monkeypatch.setattr( - server, - "read_continuous_state", + daemon_worker, + 'read_continuous_state', lambda path: SimpleNamespace(enabled=False, objective=""), ) @@ -1561,13 +1570,13 @@ def __init__(self, *, target, name, daemon): def start(self): raise RuntimeError("thread unavailable") - monkeypatch.setattr(server.threading, "Thread", BrokenThread) - server._SCHEDULED_DAEMON_UPGRADES.clear() + monkeypatch.setattr(threading, "Thread", BrokenThread) + daemon_upgrade._SCHEDULED_DAEMON_UPGRADES.clear() with pytest.raises(RuntimeError, match="thread unavailable"): - server.schedule_project_daemon_upgrade(sid, global_root=root) + daemon_upgrade.schedule_project_daemon_upgrade(sid, global_root=root) - assert str(life.resolve()) not in server._SCHEDULED_DAEMON_UPGRADES + assert str(life.resolve()) not in daemon_upgrade._SCHEDULED_DAEMON_UPGRADES assert (life / server.project_state.DAEMON_UPGRADE_REQUEST_FILE).is_file() @@ -1577,9 +1586,9 @@ def test_daemon_upgrade_drains_and_restores_continuous_mode( ) -> None: root, sid, life = ctx monkeypatch.setattr( - server, - "read_daemon_status", - lambda path: server.DaemonStatus( + daemon_worker, + 'read_daemon_status', + lambda path: daemon_worker.DaemonStatus( alive=True, pid=321, started_at_iso=None, @@ -1590,29 +1599,29 @@ def test_daemon_upgrade_drains_and_restores_continuous_mode( ) stops = [] monkeypatch.setattr( - server, - "stop_daemon", + daemon_worker, + 'stop_daemon', lambda *args, **kwargs: stops.append(kwargs) or 0, ) monkeypatch.setattr( - server, - "read_continuous_state", + daemon_worker, + 'read_continuous_state', lambda path: SimpleNamespace(enabled=True, objective="keep researching"), ) writes = [] monkeypatch.setattr( - server, - "write_continuous_config", + daemon_worker, + 'write_continuous_config', lambda path, **kwargs: writes.append((path, kwargs)), ) starts = [] monkeypatch.setattr( - server, - "start_project_daemon", + daemon_lifecycle, + 'start_project_daemon', lambda project_id, **kwargs: starts.append((project_id, kwargs)) or {"rc": 0}, ) - result = server.upgrade_project_daemon(sid, global_root=root) + result = daemon_upgrade.upgrade_project_daemon(sid, global_root=root) assert result == {"rc": 0, "upgraded": True} assert stops == [ @@ -1642,14 +1651,14 @@ def test_daemon_upgrade_schedules_restart_when_active_mission_is_still_running( source = root / "checkout" source.mkdir() monkeypatch.setattr( - server, - "runtime_identity", + runtime_identity_module, + 'runtime_identity', lambda: {"source_root": str(source)}, ) monkeypatch.setattr( - server, - "read_daemon_status", - lambda path: server.DaemonStatus( + daemon_worker, + 'read_daemon_status', + lambda path: daemon_worker.DaemonStatus( alive=True, pid=321, started_at_iso=None, @@ -1658,27 +1667,27 @@ def test_daemon_upgrade_schedules_restart_when_active_mission_is_still_running( pid_path=Path(path) / "daemon.pid", ), ) - monkeypatch.setattr(server, "stop_daemon", lambda *args, **kwargs: 2) + monkeypatch.setattr(daemon_worker, 'stop_daemon', lambda *args, **kwargs: 2) monkeypatch.setattr( - server, - "read_continuous_state", + daemon_worker, + 'read_continuous_state', lambda path: SimpleNamespace(enabled=True, objective="keep researching"), ) scheduled = [] monkeypatch.setattr( - server, - "schedule_project_daemon_upgrade", + daemon_upgrade, + 'schedule_project_daemon_upgrade', lambda project_id, **kwargs: ( scheduled.append((project_id, kwargs)) or {"rc": 0, "scheduled": True, "reason": "draining"} ), ) - result = server.upgrade_project_daemon(sid, global_root=root) + result = daemon_upgrade.upgrade_project_daemon(sid, global_root=root) assert result == {"rc": 0, "scheduled": True, "reason": "draining"} assert scheduled == [(sid, {"global_root": root})] - request = server._read_daemon_upgrade_request(life) + request = daemon_upgrade._read_daemon_upgrade_request(life) assert request is not None assert request["expected_pid"] == 321 assert request["resume_continuous"] is True @@ -1695,9 +1704,9 @@ def stop_daemon(target, **kwargs): calls.append((target, kwargs)) return 0 - monkeypatch.setattr(server, "stop_daemon", stop_daemon) + monkeypatch.setattr(daemon_worker, 'stop_daemon', stop_daemon) - result = server.stop_project_daemon( + result = daemon_lifecycle.stop_project_daemon( sid, force=True, global_root=root, @@ -1717,12 +1726,12 @@ def test_daemon_command_idempotency_and_revision_fencing(ctx, monkeypatch) -> No starts = [] stops = [] services = DaemonServices( - read_status=server.read_daemon_status, + read_status=daemon_worker.read_daemon_status, start=lambda project_id, **kwargs: starts.append(project_id) or {"rc": 0, "already_alive": False}, ) monkeypatch.setattr( - server, - "stop_project_daemon", + daemon_lifecycle, + 'stop_project_daemon', lambda project_id, **kwargs: stops.append(project_id) or {"rc": 0}, ) client = TestClient(server.create_app(global_root=root, daemon_services=services)) @@ -1762,7 +1771,7 @@ def test_explicit_injected_start_only_resumes_provider_fences_after_success(ctx, item = BacklogItem.new(title=status, objective="Synthetic paused work") item.status = status memory.backlog.add(item) - services = DaemonServices(read_status=server.read_daemon_status, start=lambda *_a, **_kw: {"rc": rc}) + services = DaemonServices(read_status=daemon_worker.read_daemon_status, start=lambda *_a, **_kw: {"rc": rc}) client = TestClient(server.create_app(global_root=root, daemon_services=services)) response = client.post(f"/api/projects/{sid}/daemon/start", json={"command_id": "explicit-start"}) assert response.status_code == 200 and response.json()["rc"] == rc @@ -1872,7 +1881,7 @@ def test_trash_restore_rejects_date_bucket(ctx) -> None: deleted = client.delete(f"/api/projects/{sid}").json() bucket = str(Path(deleted["trash_path"]).parent) - assert server.restore_trashed_project(bucket, global_root=root) is None + assert project_crud.restore_trashed_project(bucket, global_root=root) is None def test_trash_restore_rejects_duplicate_sid_in_another_root( @@ -1884,7 +1893,7 @@ def test_trash_restore_rejects_duplicate_sid_in_another_root( _make_project(primary, sid) _make_project(secondary, sid) services = _daemon_services(alive=False) - assert server.delete_project(sid, global_root=secondary, read_status=services.read_status)["ok"] is True + assert project_crud.delete_project(sid, global_root=secondary, read_status=services.read_status)["ok"] is True client = TestClient(server.create_app(global_root=primary, session_roots=[secondary], daemon_services=services)) entry = client.get("/api/trash").json()["entries"][0] @@ -2042,7 +2051,7 @@ def test_budget_config_does_not_report_success_when_persistence_fails( monkeypatch.setenv("ARGUS_SKILL_GLOBAL_DAILY_CAP_USD", "7") monkeypatch.setattr(knob_store, "write_persisted_knobs", lambda values: False) with pytest.raises(RuntimeError, match="could not be persisted"): - server.set_budget_config( + mission_items.set_budget_config( { "global_daily_cap": "120", "codex_daily_requests": "400", @@ -2059,7 +2068,7 @@ def test_budget_config_does_not_report_success_when_persistence_fails( def test_identity_set_and_skills_and_reset(ctx, monkeypatch) -> None: root, sid, life = ctx - monkeypatch.setattr(server, "run_skill_command", lambda tokens, **_kwargs: "skills:" + " ".join(tokens)) + monkeypatch.setattr(mission_items, 'run_skill_command', lambda tokens, **_kwargs: "skills:" + " ".join(tokens)) monkeypatch.setattr( "argus.webapi.manager_state.reset_manager_context", lambda sid, *, global_root=None: True, @@ -2081,8 +2090,8 @@ def test_identity_set_and_skills_and_reset(ctx, monkeypatch) -> None: def test_post_unknown_project_404(ctx, monkeypatch) -> None: root, _, _ = ctx - monkeypatch.setattr(server, "spawn_detached_daemon", lambda *a, **k: 0) - monkeypatch.setattr(server, "stop_daemon", lambda *a, **k: 0) + monkeypatch.setattr(daemon_worker, 'spawn_detached_daemon_clean', lambda *a, **k: 0) + monkeypatch.setattr(daemon_worker, 'stop_daemon', lambda *a, **k: 0) client = TestClient(server.create_app(global_root=root)) for path, body in [ ("tasks", {"text": "x"}), diff --git a/tests/webapi/test_control_responsiveness.py b/tests/webapi/test_control_responsiveness.py index 88365701d..7ae3a57c4 100644 --- a/tests/webapi/test_control_responsiveness.py +++ b/tests/webapi/test_control_responsiveness.py @@ -11,11 +11,12 @@ from argus.core.models import RunnerOptions, RunnerResult from argus.core.run_gateway import run_exec from argus.core.session import SessionMeta, write_session_meta +from argus.daemon import life_worker as daemon_worker from argus.daemon.state import read_continuous_state, write_continuous_config from argus.life.memory import LifeMemory, MemoryBundle from argus.manager import config_intent, front_door from argus.manager._session_ops import manager_pipeline_lock -from argus.webapi import manager_bridge, manager_dispatch, manager_state, server +from argus.webapi import manager_bridge, manager_dispatch, manager_state, project_crud, server from argus.webapi.daemon_services import DaemonServices @@ -85,7 +86,7 @@ def classify(*args, **kwargs): return None, None, "complex" monkeypatch.setattr(config_intent, "_front_door_classify", classify) - monkeypatch.setattr(server, "stop_daemon", lambda *args, **kwargs: 0) + monkeypatch.setattr(daemon_worker, 'stop_daemon', lambda *args, **kwargs: 0) with ThreadPoolExecutor(max_workers=1) as pool, TestClient(server.create_app(global_root=tmp_path)) as client: future = pool.submit(manager_bridge.manager_message, sid, "Develop the next experiment", global_root=tmp_path) try: @@ -134,9 +135,9 @@ def holder(): old = new = None try: assert held.wait(1) - old = pool.submit(server.set_continuous, sid, enabled=True, objective="Old goal", global_root=tmp_path) + old = pool.submit(project_crud.set_continuous, sid, enabled=True, objective="Old goal", global_root=tmp_path) assert prepared.wait(2) - new = pool.submit(server.set_continuous, sid, enabled=True, objective="New goal", global_root=tmp_path) + new = pool.submit(project_crud.set_continuous, sid, enabled=True, objective="New goal", global_root=tmp_path) with pytest.raises(front_door.ManagerHandoffSupersededError): old.result(timeout=1) assert commits == [] and not holding.done() @@ -170,10 +171,10 @@ def commit(mem, objective, state, *, cancelled=None, **kwargs): monkeypatch.setattr(config_intent, "_front_door_classify", lambda *args, **kwargs: (None, None, "complex")) monkeypatch.setattr(front_door, "manager_continuous_handoff", commit) with ThreadPoolExecutor(max_workers=1, thread_name_prefix="older") as pool: - old = pool.submit(server.set_continuous, sid, enabled=True, objective="Old", global_root=tmp_path) + old = pool.submit(project_crud.set_continuous, sid, enabled=True, objective="Old", global_root=tmp_path) try: assert opened.wait(1) - assert server.set_continuous(sid, enabled=True, objective="New", global_root=tmp_path) + assert project_crud.set_continuous(sid, enabled=True, objective="New", global_root=tmp_path) release.set() with pytest.raises(front_door.ManagerHandoffSupersededError): old.result(timeout=1) @@ -196,7 +197,7 @@ def commit(mem, objective, state, **kwargs): monkeypatch.setattr(config_intent, "_front_door_classify", lambda *args, **kwargs: (None, None, "complex")) monkeypatch.setattr(front_door, "manager_continuous_handoff", commit) - services = DaemonServices(read_status=server.read_daemon_status, + services = DaemonServices(read_status=daemon_worker.read_daemon_status, start=lambda *args, **kwargs: starts.append(True) or {"rc": 0}) app = server.create_app(global_root=tmp_path, daemon_services=services) with TestClient(app) as client, ThreadPoolExecutor(max_workers=1) as pool: @@ -225,7 +226,7 @@ def fail(*args, **kwargs): monkeypatch.setattr(config_intent, "_front_door_classify", fail) with pytest.raises(front_door.ManagerHandoffError): - server.set_continuous(sid, enabled=True, objective="Replacement", global_root=tmp_path) + project_crud.set_continuous(sid, enabled=True, objective="Replacement", global_root=tmp_path) assert read_continuous_state(life).objective == state["continuous_objective"] == "Previous" assert read_continuous_state(life).enabled and state["config"]["continuous"] diff --git a/tests/webapi/test_daemon_services.py b/tests/webapi/test_daemon_services.py index ad84db39c..12bb70a09 100644 --- a/tests/webapi/test_daemon_services.py +++ b/tests/webapi/test_daemon_services.py @@ -9,9 +9,17 @@ from fastapi.testclient import TestClient from argus.core.session import SessionMeta, write_session_meta +from argus.daemon import life_worker as daemon_worker from argus.daemon.state import DaemonStatus from argus.life.memory import LifeMemory -from argus.webapi import manager_dispatch, mission_items, project_crud, server +from argus.webapi import ( + daemon_lifecycle, + manager_dispatch, + mission_items, + project_crud, + project_state, + server, +) from argus.webapi.daemon_services import DaemonServices from argus.webapi.index_cache import CacheWaitTimeout @@ -52,8 +60,8 @@ def start(project_id, *, selected=index, **options): def wrong_global(*_args, **_kwargs): pytest.fail("request consulted a mutable server service instead of its app dependency") - monkeypatch.setattr(server, "read_daemon_status", wrong_global) - monkeypatch.setattr(server, "start_project_daemon", wrong_global) + monkeypatch.setattr(daemon_worker, 'read_daemon_status', wrong_global) + monkeypatch.setattr(daemon_lifecycle, 'start_project_daemon', wrong_global) # Interleave requests to expose process-global injection, including the # actual start command receipt and the real trash move. for index in (1, 0, 1): @@ -98,7 +106,7 @@ def start(project_id, **options): def wrong_global(*_args, **_kwargs): pytest.fail("task command bypassed its injected starter") - monkeypatch.setattr(server, "start_project_daemon", wrong_global) + monkeypatch.setattr(daemon_lifecycle, 'start_project_daemon', wrong_global) response = client.post( f"/api/projects/{sid}/tasks", json={"text": "Verify the local service boundary", "autostart_daemon": autostart}, @@ -124,7 +132,7 @@ def test_direct_business_calls_use_concrete_defaults_without_server_lookup(tmp_p def wrong_global(*_args, **_kwargs): pytest.fail("direct business call looked up the server module") - monkeypatch.setattr(server, "read_daemon_status", wrong_global) + monkeypatch.setattr(daemon_worker, 'read_daemon_status', wrong_global) assert mission_items.get_status(sid, global_root=tmp_path)["daemon"]["alive"] is False result = project_crud.delete_project(sid, global_root=tmp_path) assert result["ok"] is True @@ -149,7 +157,7 @@ def test_cache_wait_timeout_returns_retryable_http_response(tmp_path, monkeypatc def timed_out(**kwargs): raise CacheWaitTimeout() - monkeypatch.setattr(server, "list_projects", timed_out) + monkeypatch.setattr(project_state, 'list_projects', timed_out) response = TestClient(server.create_app(global_root=tmp_path)).get("/api/projects") assert response.status_code == 503 assert response.json() == {"detail": "Snapshot refresh timed out; retry shortly."} diff --git a/tests/webapi/test_dispatch_receipt_races.py b/tests/webapi/test_dispatch_receipt_races.py index 2177fb78f..c73ee61ff 100644 --- a/tests/webapi/test_dispatch_receipt_races.py +++ b/tests/webapi/test_dispatch_receipt_races.py @@ -12,6 +12,7 @@ from argus.core.session import SessionMeta, write_session_meta from argus.core.transcript import read_turns +from argus.daemon import life_worker as daemon_worker from argus.daemon.commands import daemon_command_execution_lock from argus.webapi import manager_bridge, server from argus.webapi.daemon_services import DaemonServices @@ -61,7 +62,7 @@ def delayed_open(path, *args, **kwargs): return real_open(path, *args, **kwargs) monkeypatch.setattr(Path, "open", delayed_open) - services = DaemonServices(read_status=server.read_daemon_status, + services = DaemonServices(read_status=daemon_worker.read_daemon_status, start=lambda *_args, **_kwargs: {"rc": 0, "alive": True}) with TestClient(server.create_app(global_root=tmp_path, daemon_services=services)) as client: with ThreadPoolExecutor(max_workers=1) as pool: @@ -105,7 +106,7 @@ def test_duplicate_without_pending_work_ignores_unrelated_control_lock( def forbidden(*_args, **_kwargs): raise AssertionError("A completed or operator-paused task must not restart") - services = DaemonServices(read_status=server.read_daemon_status, start=forbidden) + services = DaemonServices(read_status=daemon_worker.read_daemon_status, start=forbidden) with TestClient(server.create_app(global_root=tmp_path, daemon_services=services)) as client: # The actual lifecycle lock is owned by this test thread while the # unrelated HTTP worker handles a completed/paused task replay. @@ -135,7 +136,7 @@ def test_cancel_during_chat_status_read_suppresses_the_stale_terminal_result( life.mkdir(parents=True) write_session_meta(tmp_path, SessionMeta(id=sid, cwd=str(life), workdir=str(life))) entered, release = threading.Event(), threading.Event() - real_read = server.read_daemon_status + real_read = daemon_worker.read_daemon_status historical_reply = "这是已经完成并保存的回复。" def manager(*_args, **_kwargs): @@ -148,7 +149,7 @@ def read_status(root): return real_read(root) monkeypatch.setattr(manager_bridge, "manager_message", manager) - monkeypatch.setattr(server, "read_daemon_status", read_status) + monkeypatch.setattr(daemon_worker, 'read_daemon_status', read_status) with TestClient(server.create_app(global_root=tmp_path)) as client: with ThreadPoolExecutor(max_workers=1) as pool: pending = pool.submit(client.post, f"/api/projects/{sid}/message" + ("/stream" if streaming else ""), @@ -189,7 +190,7 @@ def forbidden(*_args, **_kwargs): monkeypatch.setattr(AgentCliBackend, "run_exec", forbidden) monkeypatch.setattr(config_intent, "_front_door_classify", lambda *_args, **_kwargs: (None, "pause", "simple")) - services = DaemonServices(read_status=server.read_daemon_status, start=forbidden) + services = DaemonServices(read_status=daemon_worker.read_daemon_status, start=forbidden) with TestClient(server.create_app(global_root=tmp_path, daemon_services=services)) as client: response = client.post(f"/api/projects/{sid}/message" + ("/stream" if streaming else ""), json={"text": "先暂停一下"}) diff --git a/tests/webapi/test_dispatch_receipt_truth.py b/tests/webapi/test_dispatch_receipt_truth.py index 7f154d3f2..5f61d4c24 100644 --- a/tests/webapi/test_dispatch_receipt_truth.py +++ b/tests/webapi/test_dispatch_receipt_truth.py @@ -13,6 +13,7 @@ from argus.adapters.agent_cli_backend import AgentCliBackend from argus.core.session import SessionMeta, write_session_meta from argus.core.transcript import read_turns +from argus.daemon import life_worker as daemon_worker from argus.life.memory import Backlog from argus.manager import Manager, config_intent, dispatch, front_door from argus.manager.domain_author import VerticalDecision @@ -75,7 +76,7 @@ def start(_sid, **_kwargs): return {"rc": 0, "alive": True, "pid": 77, "control_available": True} app = server.create_app(global_root=tmp_path, daemon_services=DaemonServices( - read_status=server.read_daemon_status, start=start, + read_status=daemon_worker.read_daemon_status, start=start, )) with TestClient(app) as client: suffix = "/message/stream" if streaming else "/message" @@ -132,7 +133,7 @@ def start(*_args, **_kwargs): assert release.wait(4) return {"rc": 0, "alive": True, "pid": 77} - services = DaemonServices(read_status=server.read_daemon_status, start=start) + services = DaemonServices(read_status=daemon_worker.read_daemon_status, start=start) with TestClient(server.create_app(global_root=tmp_path, daemon_services=services)) as client: with ThreadPoolExecutor(max_workers=1) as pool: pending = pool.submit(client.post, f"/api/projects/{sid}/message" + ("/stream" if streaming else ""), diff --git a/tests/webapi/test_domain_intake_flow.py b/tests/webapi/test_domain_intake_flow.py index cebd02e21..9cf0fe17a 100644 --- a/tests/webapi/test_domain_intake_flow.py +++ b/tests/webapi/test_domain_intake_flow.py @@ -5,6 +5,7 @@ from fastapi.testclient import TestClient from argus.core.session import SessionMeta, read_session_meta, write_session_meta +from argus.daemon import life_worker as daemon_worker from argus.life.memory import Backlog from argus.manager import Manager, config_intent, front_door from argus.manager.domain_author import VerticalDecision, parse_domain_proposal @@ -72,7 +73,7 @@ def start(*_a, **_kw): return {"rc": 0, "alive": True, "pid": 77, "control_available": True} app = server.create_app(global_root=tmp_path, daemon_services=DaemonServices( - read_status=server.read_daemon_status, start=start, + read_status=daemon_worker.read_daemon_status, start=start, )) with TestClient(app) as client: url = f"/api/projects/{sid}/message" @@ -176,7 +177,7 @@ def execute(_mem, text, _state, **kwargs): monkeypatch.setattr(config_intent, '_front_door_classify', unexpected) monkeypatch.setattr(front_door, 'manager_triage', execute) app = server.create_app(global_root=tmp_path, daemon_services=DaemonServices( - read_status=server.read_daemon_status, start=unexpected, + read_status=daemon_worker.read_daemon_status, start=unexpected, )) payload = {'text': '/stop', 'domain_answer': {'id': card['id'], 'option_id': 'direct', 'note': 'Show the result'}} with TestClient(app) as client: diff --git a/tests/webapi/test_explicit_provider_resume.py b/tests/webapi/test_explicit_provider_resume.py index a60182591..82a1c9447 100644 --- a/tests/webapi/test_explicit_provider_resume.py +++ b/tests/webapi/test_explicit_provider_resume.py @@ -5,7 +5,7 @@ from argus.core.session import SessionMeta, write_session_meta from argus.life.memory import BacklogItem, LifeMemory -from argus.webapi import server +from argus.webapi import daemon_lifecycle, server from argus.webapi.routes.daemon import _resume_provider_fences_after_start @@ -38,7 +38,7 @@ def test_authenticated_idempotent_start_is_the_only_resume_action(tmp_path, monk def start(*args, **kwargs): calls.append(1) return {"rc": 0, "sid": sid} - monkeypatch.setattr(server, "start_project_daemon", start) + monkeypatch.setattr(daemon_lifecycle, 'start_project_daemon', start) client = TestClient(server.create_app(global_root=tmp_path, auth_token="fixture")) route = f"/api/projects/{sid}/daemon/start" assert client.post(route).status_code == 401 diff --git a/tests/webapi/test_live_daemon_is_visible.py b/tests/webapi/test_live_daemon_is_visible.py index 32adac81c..c2b30ff40 100644 --- a/tests/webapi/test_live_daemon_is_visible.py +++ b/tests/webapi/test_live_daemon_is_visible.py @@ -51,7 +51,7 @@ def root(tmp_path: Path) -> Path: def test_a_running_daemon_without_a_session_is_still_listed(root: Path) -> None: ctx = _Ctx(root, [_project("e8d2340c8962", alive=True)]) - listed = ctx.machine_projects(limit=50, include_empty=False) + listed = ctx._machine_projects_uncached(limit=50, include_empty=False) assert [p["id"] for p in listed] == ["e8d2340c8962"] @@ -60,12 +60,12 @@ def test_inert_hex_litter_is_still_hidden(root: Path) -> None: """The reason the filter exists in the first place.""" ctx = _Ctx(root, [_project("e8d2340c8962", alive=False)]) - assert ctx.machine_projects(limit=50, include_empty=False) == [] + assert ctx._machine_projects_uncached(limit=50, include_empty=False) == [] def test_a_real_session_is_listed_either_way(root: Path) -> None: ctx = _Ctx(root, [_project("s-140a0353", alive=False)]) - listed = ctx.machine_projects(limit=50, include_empty=False) + listed = ctx._machine_projects_uncached(limit=50, include_empty=False) assert [p["id"] for p in listed] == ["s-140a0353"] diff --git a/tests/webapi/test_maintenance_decision_cleanup.py b/tests/webapi/test_maintenance_decision_cleanup.py index 316c14807..d87e395e4 100644 --- a/tests/webapi/test_maintenance_decision_cleanup.py +++ b/tests/webapi/test_maintenance_decision_cleanup.py @@ -11,6 +11,7 @@ from argus.core.session import SessionMeta, write_session_meta from argus.daemon.state import GRACEFUL_STOP_REASON, write_continuous_config from argus.life.memory import BacklogItem, MemoryBundle +from argus.webapi import daemon_lifecycle from argus.webapi.manager_pending_question import manager_resolve_operator_decision @@ -273,7 +274,7 @@ def test_http_decline_does_not_start_a_daemon( before = (mem.project_root / "continuous.json").read_bytes() starts = [] monkeypatch.setattr( - server, "start_project_daemon", + daemon_lifecycle, 'start_project_daemon', lambda *args, **kwargs: starts.append((args, kwargs)) or {"rc": 0}, ) prefix = f"/api/projects/{mem.project.fingerprint}" diff --git a/tests/webapi/test_message.py b/tests/webapi/test_message.py index a164e9677..520f8ae6f 100644 --- a/tests/webapi/test_message.py +++ b/tests/webapi/test_message.py @@ -26,13 +26,18 @@ write_session_meta, ) from argus.core.transcript import append_turn +from argus.daemon import life_worker as daemon_worker from argus.life.memory import Backlog, BacklogItem, LifeMemory from argus.manager import Manager, config_intent, dispatch, front_door from argus.manager.domain_author import VerticalDecision from argus.webapi import ( + daemon_lifecycle, manager_bridge, manager_dispatch, + manager_pending_question, manager_state, + mission_items, + project_crud, project_state, server, ) @@ -63,7 +68,7 @@ def _client_with_starter(root: Path, start: ProjectDaemonStarter) -> TestClient: _make_project(root) return TestClient(server.create_app( global_root=root, - daemon_services=DaemonServices(read_status=server.read_daemon_status, start=start), + daemon_services=DaemonServices(read_status=daemon_worker.read_daemon_status, start=start), )) @@ -1758,8 +1763,8 @@ def run_exec(self, **_kwargs): lambda *args, **kwargs: False, ) monkeypatch.setattr( - server, - "start_project_daemon", + daemon_lifecycle, + 'start_project_daemon', lambda *args, **kwargs: {"alive": True}, ) client = TestClient(server.create_app(global_root=tmp_path)) @@ -1783,8 +1788,6 @@ def run_exec(self, **_kwargs): assert state["research_target_level"] == "exploratory" - - def test_message_empty_400(client: TestClient) -> None: assert client.post("/api/projects/s-msgtest0/message", json={"text": " "}).status_code == 400 @@ -1820,8 +1823,8 @@ def test_explicit_pending_answer_continues_without_a_model_call( ) started: list[str] = [] monkeypatch.setattr( - server, - "start_project_daemon", + daemon_lifecycle, + 'start_project_daemon', lambda sid, *, global_root=None, resume_continuous=False, reclaim_idle=False: started.append(sid) or {"rc": 0}, ) @@ -1930,7 +1933,7 @@ def test_concurrent_pending_answers_create_one_continuation( with ThreadPoolExecutor(max_workers=2) as pool: results = list(pool.map( - lambda answer: server.answer_pending_question( + lambda answer: manager_pending_question.manager_answer_pending_question( "s-msgtest0", blocked.id, answer, @@ -2422,7 +2425,7 @@ def fake_spawn(cfg, quiet=True): spawned["resume_continuous"] = cfg.resume_continuous return 0 - monkeypatch.setattr(server, "spawn_detached_daemon", fake_spawn) + monkeypatch.setattr(daemon_worker, 'spawn_detached_daemon_clean', fake_spawn) client = TestClient(server.create_app(global_root=tmp_path)) r = client.post("/api/daemons", json={"objective": "reproduce the recursive kernel task", "name": "kbench"}) assert r.status_code == 200 @@ -2449,8 +2452,8 @@ def test_create_daemon_persists_only_manager_execution_handoff( spawned: dict[str, object] = {} _install_manager(monkeypatch, lambda text: "write the MRAM paper") monkeypatch.setattr( - server, - "spawn_detached_daemon", + daemon_worker, + 'spawn_detached_daemon_clean', lambda cfg, quiet=True: spawned.update( objective=cfg.continuous_objective, ) or 0, @@ -2466,7 +2469,7 @@ def _name_from_front_door(mem, text, chat_state, **_kwargs): monkeypatch.setattr(config_intent, "_front_door_classify", _name_from_front_door) raw = "write the MRAM paper; Manager owns the right sidebar" - result = server.create_daemon(objective=raw, global_root=tmp_path) + result = daemon_lifecycle.create_daemon(objective=raw, global_root=tmp_path) life_dir = tmp_path / "projects" / result["sid"] continuous = json.loads((life_dir / "continuous.json").read_text()) @@ -2489,8 +2492,8 @@ def test_named_daemon_uses_manager_lifetime( expected_open_ended: bool, ) -> None: monkeypatch.setattr( - server, - "spawn_detached_daemon", + daemon_worker, + 'spawn_detached_daemon_clean', lambda *_args, **_kwargs: 0, ) @@ -2500,7 +2503,7 @@ def _classify(mem, text, chat_state, **_kwargs): monkeypatch.setattr(config_intent, "_front_door_classify", _classify) - result = server.create_daemon( + result = daemon_lifecycle.create_daemon( objective="write one reviewed report and stop", name="finite report", global_root=tmp_path, @@ -2517,14 +2520,14 @@ def test_create_daemon_preserves_manual_rename_during_manager_handoff( monkeypatch, ) -> None: monkeypatch.setattr( - server, - "spawn_detached_daemon", + daemon_worker, + 'spawn_detached_daemon_clean', lambda *_args, **_kwargs: 0, ) def _handoff(sid, objective, *, global_root=None, name_session=False): assert name_session is True - renamed = server.update_project( + renamed = project_crud.update_project( sid, name="Operator title", global_root=global_root, @@ -2534,7 +2537,7 @@ def _handoff(sid, objective, *, global_root=None, name_session=False): monkeypatch.setattr(manager_dispatch, "manager_continuous_handoff", _handoff) - result = server.create_daemon( + result = daemon_lifecycle.create_daemon( objective="raw operator objective", global_root=tmp_path, ) @@ -2547,7 +2550,7 @@ def _handoff(sid, objective, *, global_root=None, name_session=False): def test_create_daemon_normalizes_explicit_name(tmp_path: Path) -> None: - result = server.create_daemon( + result = daemon_lifecycle.create_daemon( name=" Concise\n session name ", global_root=tmp_path, ) @@ -2563,13 +2566,13 @@ def test_direct_task_names_an_idle_session_from_its_first_task( ) -> None: _install_manager(monkeypatch, lambda text: text, session_title="Local task verification") monkeypatch.setattr( - server, - "spawn_detached_daemon", + daemon_worker, + 'spawn_detached_daemon_clean', lambda *_args, **_kwargs: 0, ) - created = server.create_daemon(global_root=tmp_path) + created = daemon_lifecycle.create_daemon(global_root=tmp_path) - item = server.enqueue_task( + item = mission_items.enqueue_task( created["sid"], "first direct task", global_root=tmp_path, @@ -2590,7 +2593,7 @@ def test_create_daemon_without_objective_is_idle(tmp_path: Path, monkeypatch) -> # session, DON'T arm continuous, DON'T spawn. The Manager writes objectives # later via /message (which lazily spawns). spawned: list[object] = [] - monkeypatch.setattr(server, "spawn_detached_daemon", lambda cfg, quiet=True: spawned.append(1) or 0) + monkeypatch.setattr(daemon_worker, 'spawn_detached_daemon_clean', lambda cfg, quiet=True: spawned.append(1) or 0) client = TestClient(server.create_app(global_root=tmp_path)) r = client.post("/api/daemons", json={}) assert r.status_code == 200 @@ -2614,7 +2617,7 @@ def test_create_daemon_never_overwrites_global_budget( encoding="utf-8", ) - server.create_daemon(global_root=tmp_path) + daemon_lifecycle.create_daemon(global_root=tmp_path) assert json.loads(config.read_text())["ARGUS_SKILL_GLOBAL_DAILY_CAP_USD"] == "4321" @@ -2633,7 +2636,7 @@ def test_create_daemon_at_cap_returns_replacement_candidates( def fake_status(path): path = Path(path) alive = path.name == "s-running01" - return server.DaemonStatus( + return daemon_worker.DaemonStatus( alive=alive, pid=99 if alive else None, started_at_iso=None, @@ -2642,10 +2645,10 @@ def fake_status(path): pid_path=path / "daemon.pid", ) - monkeypatch.setattr(server, "read_daemon_status", fake_status) + monkeypatch.setattr(daemon_worker, 'read_daemon_status', fake_status) monkeypatch.setattr(project_state, "read_daemon_status", fake_status) - monkeypatch.setattr(server, "_max_active_daemons", lambda config: 1) - monkeypatch.setattr(server, "_active_daemon_count", lambda config: 1) + monkeypatch.setattr(daemon_lifecycle, '_max_active_daemons', lambda config: 1) + monkeypatch.setattr(daemon_lifecycle, '_active_daemon_count', lambda config: 1) client = TestClient(server.create_app(global_root=tmp_path)) body = client.post( @@ -2665,10 +2668,10 @@ def test_fresh_idle_daemon_survives_concurrent_startup_gc(tmp_path: Path) -> Non created empty session must survive that sweep.""" from argus.core.project_gc import gc_stale_projects - created = server.create_daemon(global_root=tmp_path) + created = daemon_lifecycle.create_daemon(global_root=tmp_path) sid = created["sid"] assert gc_stale_projects(tmp_path, now=time.time() + 2) == [] - assert server.project_life_dir(sid, global_root=tmp_path) is not None + assert project_state.project_life_dir(sid, global_root=tmp_path) is not None def test_web_daemon_config_uses_resolved_role_models_and_efforts( @@ -2685,7 +2688,7 @@ def test_web_daemon_config_uses_resolved_role_models_and_efforts( tmp_path, SessionMeta(id=life_dir.name, cwd=str(life_dir), workdir=str(life_dir)), ) - cfg = server._worker_config_from_env(life_dir, tmp_path) + cfg = daemon_lifecycle._worker_config_from_env(life_dir, tmp_path) assert cfg.project_workdir == life_dir.resolve() assert cfg.engineer_model == "engineer-model" assert cfg.reviewer_model == "reviewer-model" @@ -2710,7 +2713,7 @@ def test_web_daemon_config_uses_persisted_session_workdir(tmp_path: Path) -> Non ), ) - cfg = server._worker_config_from_env(life_dir, tmp_path) + cfg = daemon_lifecycle._worker_config_from_env(life_dir, tmp_path) assert cfg.life_dir == life_dir assert cfg.project_workdir == workspace.resolve() @@ -2729,7 +2732,7 @@ def test_web_daemon_config_does_not_migrate_legacy_launch_cwd( SessionMeta(id=sid, cwd=str(life_dir), launch_cwd=str(launch)), ) - cfg = server._worker_config_from_env(life_dir, tmp_path) + cfg = daemon_lifecycle._worker_config_from_env(life_dir, tmp_path) assert cfg.project_workdir == life_dir.resolve() @@ -2744,12 +2747,12 @@ def test_web_daemon_config_migrates_legacy_daemon_workdir( life_dir.mkdir(parents=True) workspace.mkdir() monkeypatch.setattr( - server, - "read_daemon_status", + daemon_worker, + 'read_daemon_status', lambda _path: SimpleNamespace(project_workdir=str(workspace)), ) - cfg = server._worker_config_from_env(life_dir, tmp_path) + cfg = daemon_lifecycle._worker_config_from_env(life_dir, tmp_path) meta = read_session_meta(tmp_path, sid) assert cfg.project_workdir == workspace.resolve() @@ -2771,12 +2774,12 @@ def test_web_daemon_config_repairs_incomplete_session_metadata( SessionMeta(id=sid, display_name="Named before launch", created=123.0), ) monkeypatch.setattr( - server, - "read_daemon_status", + daemon_worker, + 'read_daemon_status', lambda _path: SimpleNamespace(project_workdir=str(workspace)), ) - cfg = server._worker_config_from_env(life_dir, tmp_path) + cfg = daemon_lifecycle._worker_config_from_env(life_dir, tmp_path) meta = read_session_meta(tmp_path, sid) assert cfg.project_workdir == workspace.resolve() @@ -2794,7 +2797,7 @@ def test_web_daemon_config_refuses_legacy_session_without_workdir( life_dir.mkdir(parents=True) with pytest.raises(FileNotFoundError, match="no trustworthy workdir"): - server._worker_config_from_env(life_dir, tmp_path) + daemon_lifecycle._worker_config_from_env(life_dir, tmp_path) assert read_session_meta(tmp_path, sid) is None @@ -2806,7 +2809,7 @@ def test_web_daemon_start_reports_legacy_session_without_workdir( life_dir = tmp_path / "projects" / sid life_dir.mkdir(parents=True) - result = server.start_project_daemon(sid, global_root=tmp_path) + result = daemon_lifecycle.start_project_daemon(sid, global_root=tmp_path) assert result is not None assert result["rc"] == 3 @@ -2831,6 +2834,6 @@ def test_web_daemon_config_honors_persisted_runner_backend( tmp_path, SessionMeta(id=life_dir.name, cwd=str(life_dir), workdir=str(life_dir)), ) - cfg = server._worker_config_from_env(life_dir, tmp_path) + cfg = daemon_lifecycle._worker_config_from_env(life_dir, tmp_path) assert cfg.backend == "copilot" diff --git a/tests/webapi/test_message_cancellation_lifecycle.py b/tests/webapi/test_message_cancellation_lifecycle.py index f54b92b1e..f779fd286 100644 --- a/tests/webapi/test_message_cancellation_lifecycle.py +++ b/tests/webapi/test_message_cancellation_lifecycle.py @@ -25,7 +25,7 @@ def endpoints(root): resolve_or_404=lambda sid: root, daemon_services=SimpleNamespace(start=lambda *args, **kwargs: starts.append(True)), ) - manager_routes.register_manager_routes(app, context, SimpleNamespace()) + manager_routes.register_manager_routes(app, context) message = next(route.endpoint for route in app.routes if getattr(route, "path", "") == "/api/projects/{sid}/message") cancel = next(route.endpoint for route in app.routes if getattr(route, "path", "") == "/api/projects/{sid}/message/cancel") return message, cancel, starts diff --git a/tests/webapi/test_message_stop_delivery.py b/tests/webapi/test_message_stop_delivery.py index acd61b519..1c2651a40 100644 --- a/tests/webapi/test_message_stop_delivery.py +++ b/tests/webapi/test_message_stop_delivery.py @@ -10,6 +10,7 @@ from argus.core.models import RunnerOptions, RunnerResult from argus.core.run_gateway import run_exec from argus.core.session import SessionMeta, write_session_meta +from argus.daemon import life_worker as daemon_worker from argus.manager import config_intent from argus.webapi import manager_bridge, server from argus.webapi.daemon_services import DaemonServices @@ -65,7 +66,7 @@ def test_stop_between_handoff_and_http_delivery_cannot_restart_executor( write_session_meta(tmp_path, SessionMeta(id=sid, cwd=str(life), workdir=str(life))) entered, release = threading.Event(), threading.Event() starts, acknowledgements = [], [] - real_read = server.read_daemon_status + real_read = daemon_worker.read_daemon_status def read_status(root): # Hold only the delivery's status read. Stop uses the actual route, @@ -80,8 +81,8 @@ def handoff(*args, **kwargs): return {"kind": "task", "item": {"id": "item-1", "status": "pending"}} monkeypatch.setattr(manager_bridge, "manager_message", handoff) - monkeypatch.setattr(server, "read_daemon_status", read_status) - monkeypatch.setattr(server, "stop_daemon", lambda *args, **kwargs: 0) + monkeypatch.setattr(daemon_worker, 'read_daemon_status', read_status) + monkeypatch.setattr(daemon_worker, 'stop_daemon', lambda *args, **kwargs: 0) monkeypatch.setattr("argus.webapi.manager_pending_question.record_task_dispatch_ack", lambda *args, **kwargs: acknowledgements.append(True)) services = DaemonServices(read_status=real_read, @@ -120,7 +121,7 @@ def test_replayed_message_only_starts_work_still_pending(tmp_path, monkeypatch, "kind": "task", "dispatch_state": "already_queued", "item": {"id": "item-1", "status": status}, }) starts = [] - services = DaemonServices(read_status=server.read_daemon_status, + services = DaemonServices(read_status=daemon_worker.read_daemon_status, start=lambda *args, **kwargs: starts.append(True) or {"rc": 0}) with TestClient(server.create_app(global_root=tmp_path, daemon_services=services)) as client: response = client.post(f"/api/projects/{sid}/message" + ("/stream" if streaming else ""), json={"text": "Continue"}) @@ -151,7 +152,7 @@ def classify(*args, **kwargs): monkeypatch.setattr(config_intent, "_front_door_classify", classify) starts = [] - services = DaemonServices(read_status=server.read_daemon_status, + services = DaemonServices(read_status=daemon_worker.read_daemon_status, start=lambda *args, **kwargs: starts.append(True) or {"rc": 0}) with TestClient(server.create_app(global_root=tmp_path, daemon_services=services)) as client: with ThreadPoolExecutor(max_workers=1) as pool: diff --git a/tests/webapi/test_pending_answer_cancellation.py b/tests/webapi/test_pending_answer_cancellation.py index f641f226c..ba1d74ded 100644 --- a/tests/webapi/test_pending_answer_cancellation.py +++ b/tests/webapi/test_pending_answer_cancellation.py @@ -11,6 +11,7 @@ from argus.adapters.agent_cli_backend import AgentCliBackend from argus.core.session import SessionMeta, write_session_meta from argus.core.transcript import read_turns +from argus.daemon import life_worker as daemon_worker from argus.life.memory import BacklogItem, LifeMemory from argus.manager import front_door from argus.webapi import server @@ -54,7 +55,7 @@ def interpret(_memory, body, _state, **_kwargs): monkeypatch.setattr(AgentCliBackend, "run_exec", forbidden) monkeypatch.setattr(front_door, "manager_triage", interpret) services = DaemonServices( - read_status=server.read_daemon_status, + read_status=daemon_worker.read_daemon_status, start=lambda *_args, **_kwargs: starts.append(True) or {"rc": 0}, ) path = f"/api/projects/{sid}/message" + ("/stream" if streaming else "") diff --git a/tests/webapi/test_project_index_cache_freshness.py b/tests/webapi/test_project_index_cache_freshness.py index 955986ff8..c3020102f 100644 --- a/tests/webapi/test_project_index_cache_freshness.py +++ b/tests/webapi/test_project_index_cache_freshness.py @@ -18,7 +18,7 @@ import pytest from argus.core.session import SessionMeta, write_session_meta -from argus.webapi import server +from argus.webapi import project_crud, project_state, server fastapi = pytest.importorskip("fastapi") from fastapi.testclient import TestClient # noqa: E402 @@ -123,13 +123,13 @@ def counting(self, **kwargs): # noqa: ANN001, ANN003 def test_repeated_trash_polls_reuse_one_scan(home: Path, monkeypatch: pytest.MonkeyPatch) -> None: scans: list[int] = [] - original = server.list_trashed_projects + original = project_crud.list_trashed_projects def counting(*, global_root): # noqa: ANN001 scans.append(1) return original(global_root=global_root) - monkeypatch.setattr(server, "list_trashed_projects", counting) + monkeypatch.setattr(project_crud, 'list_trashed_projects', counting) client = TestClient(server.create_app(global_root=home)) for _ in range(10): @@ -142,13 +142,13 @@ def test_repeated_snapshot_polls_reuse_one_build( home: Path, monkeypatch: pytest.MonkeyPatch ) -> None: builds: list[int] = [] - original = server.build_snapshot + original = project_state.build_snapshot def counting(*args, **kwargs): # noqa: ANN002, ANN003 builds.append(1) return original(*args, **kwargs) - monkeypatch.setattr(server, "build_snapshot", counting) + monkeypatch.setattr(project_state, 'build_snapshot', counting) client = TestClient(server.create_app(global_root=home)) for _ in range(10): diff --git a/tests/webapi/test_query_concurrency.py b/tests/webapi/test_query_concurrency.py index f84a2d709..ebc0aa435 100644 --- a/tests/webapi/test_query_concurrency.py +++ b/tests/webapi/test_query_concurrency.py @@ -12,7 +12,7 @@ import pytest from argus.core.session import SessionMeta, write_session_meta -from argus.webapi import server +from argus.webapi import daemon_lifecycle, project_crud, project_state, server from argus.webapi.index_cache import ( CacheWaitTimeout, IndexCache, @@ -58,16 +58,16 @@ def slow_scan(label, result): with lock: active -= 1 - monkeypatch.setattr(server, "list_projects", lambda **kw: slow_scan("index", [])) - monkeypatch.setattr(server, "list_project_costs", lambda **kw: slow_scan("costs", [])) - monkeypatch.setattr(server, "list_trashed_projects", lambda **kw: slow_scan("trash", [])) - monkeypatch.setattr(server, "build_snapshot", lambda *args, **kw: slow_scan("snapshot", {"sid": sid})) + monkeypatch.setattr(project_state, 'list_projects', lambda **kw: slow_scan("index", [])) + monkeypatch.setattr(project_state, 'list_project_costs', lambda **kw: slow_scan("costs", [])) + monkeypatch.setattr(project_crud, 'list_trashed_projects', lambda **kw: slow_scan("trash", [])) + monkeypatch.setattr(project_state, 'build_snapshot', lambda *args, **kw: slow_scan("snapshot", {"sid": sid})) def stop(project_id, **kwargs): stopped.append((project_id, threading.current_thread().name)) return {"rc": 0} - monkeypatch.setattr(server, "stop_project_daemon", stop) + monkeypatch.setattr(daemon_lifecycle, 'stop_project_daemon', stop) app = server.create_app( global_root=tmp_path, query_limits=QueryLimits(workers=2, queued=2, waiters=32, timeout_seconds=2), @@ -123,7 +123,7 @@ def scan(**kwargs): assert release.wait(timeout=3) return [] - monkeypatch.setattr(server, "list_projects", scan) + monkeypatch.setattr(project_state, 'list_projects', scan) app = server.create_app( global_root=tmp_path, query_limits=QueryLimits(workers=1, queued=0, timeout_seconds=0.06), @@ -162,7 +162,7 @@ def scan(**kwargs): assert release.wait(timeout=3) return [] - monkeypatch.setattr(server, "list_projects", scan) + monkeypatch.setattr(project_state, 'list_projects', scan) app = server.create_app(global_root=tmp_path, query_limits=QueryLimits(workers=1, queued=0)) async def exercise(): diff --git a/tests/webapi/test_server_m0.py b/tests/webapi/test_server_m0.py index cc34058fa..d3f0be3c1 100644 --- a/tests/webapi/test_server_m0.py +++ b/tests/webapi/test_server_m0.py @@ -18,6 +18,7 @@ from argus.core.session import SessionMeta, write_session_meta from argus.core.transcript import append_turn from argus.core.usage import UsageLedger, UsageRecord +from argus.daemon import life_worker as daemon_worker from argus.webapi import project_state, server from argus.webapi.protocol import ( API_CAPABILITIES, @@ -216,10 +217,10 @@ def _make_project(root: Path, sid: str = "s-testaaaa") -> Path: def test_project_life_dir_resolves_and_guards(tmp_path: Path) -> None: life = _make_project(tmp_path) - assert server.project_life_dir("s-testaaaa", global_root=tmp_path) == life.resolve() + assert project_state.project_life_dir("s-testaaaa", global_root=tmp_path) == life.resolve() # traversal + missing → None (never escapes projects/) - assert server.project_life_dir("../../etc", global_root=tmp_path) is None - assert server.project_life_dir("s-nope", global_root=tmp_path) is None + assert project_state.project_life_dir("../../etc", global_root=tmp_path) is None + assert project_state.project_life_dir("s-nope", global_root=tmp_path) is None def test_snapshot_reuses_cost_control_cache_during_transient_lock_contention( @@ -279,8 +280,8 @@ def usage(*, global_root, now=None): with project_state._GLOBAL_USAGE_CACHE_LOCK: project_state._GLOBAL_USAGE_CACHE.clear() - assert server.build_snapshot("s-host-one", global_root=tmp_path) is not None - assert server.build_snapshot("s-host-two", global_root=tmp_path) is not None + assert project_state.build_snapshot("s-host-one", global_root=tmp_path) is not None + assert project_state.build_snapshot("s-host-two", global_root=tmp_path) is not None assert calls == {"cost": 1, "usage": 1} @@ -312,7 +313,7 @@ def global_usage(*, global_root, now=None): with project_state._GLOBAL_USAGE_CACHE_LOCK: project_state._GLOBAL_USAGE_CACHE.clear() - snap = server.build_snapshot( + snap = project_state.build_snapshot( "s-usage-floor", global_root=tmp_path, compact=True, @@ -355,7 +356,7 @@ def usage(*, global_root, now=None): with project_state._HOST_REFRESHING_LOCK: project_state._HOST_REFRESHING.clear() - snap = server.build_snapshot( + snap = project_state.build_snapshot( "s-nonblocking-host", global_root=tmp_path, compact=True, @@ -611,7 +612,7 @@ def test_build_snapshot_shape_and_failsoft( ) -> None: monkeypatch.setenv("ARGUS_SKILL_GLOBAL_DAILY_CAP_USD", "55") _make_project(tmp_path) - snap = server.build_snapshot("s-testaaaa", global_root=tmp_path) + snap = project_state.build_snapshot("s-testaaaa", global_root=tmp_path) assert snap is not None assert set(snap) == { "schema_version", @@ -654,7 +655,7 @@ def test_build_snapshot_shape_and_failsoft( assert snap["usage_summary"]["call_count"] == 0 assert snap["global_usage_summary"]["call_count"] == 0 # unknown project → None (not an exception) - assert server.build_snapshot("s-nope", global_root=tmp_path) is None + assert project_state.build_snapshot("s-nope", global_root=tmp_path) is None def test_build_snapshot_reuses_host_metrics_across_project_switches( @@ -674,8 +675,8 @@ def fake_metrics_snapshot(*, root, cost_control=None): with project_state._METRICS_CACHE_LOCK: project_state._METRICS_CACHE.clear() try: - assert server.build_snapshot("s-first", global_root=tmp_path) is not None - assert server.build_snapshot("s-second", global_root=tmp_path) is not None + assert project_state.build_snapshot("s-first", global_root=tmp_path) is not None + assert project_state.build_snapshot("s-second", global_root=tmp_path) is not None assert calls == 1 finally: with project_state._METRICS_CACHE_LOCK: @@ -698,18 +699,18 @@ def slow_metrics_snapshot(*, root, cost_control=None): with project_state._METRICS_CACHE_LOCK: project_state._METRICS_CACHE.clear() try: - snap = server.build_snapshot("s-fast", global_root=tmp_path, compact=True) + snap = project_state.build_snapshot("s-fast", global_root=tmp_path, compact=True) assert snap is not None assert snap["observability"] is None assert calls == 0 - full = server.build_snapshot("s-fast", global_root=tmp_path) + full = project_state.build_snapshot("s-fast", global_root=tmp_path) assert full is not None assert full["observability"]["slo"]["status"] == "healthy" assert calls == 1 - refreshed = server.build_snapshot("s-fast", global_root=tmp_path, compact=True) + refreshed = project_state.build_snapshot("s-fast", global_root=tmp_path, compact=True) assert refreshed is not None assert refreshed["observability"]["slo"]["status"] == "healthy" assert calls == 1 @@ -728,7 +729,7 @@ def broken_status(_life_dir: Path): raise RuntimeError("status sidecar is unreadable") monkeypatch.setattr(project_state, "read_daemon_status", broken_status) - snap = server.build_snapshot("s-testaaaa", global_root=tmp_path) + snap = project_state.build_snapshot("s-testaaaa", global_root=tmp_path) assert snap is not None assert snap["partial"] is True assert snap["daemon"]["read_status"] == "error" @@ -752,7 +753,7 @@ def test_build_snapshot_marks_running_legacy_daemon_incompatible( monkeypatch.setattr( project_state, "read_daemon_status", - lambda _life_dir: server.DaemonStatus( + lambda _life_dir: daemon_worker.DaemonStatus( alive=True, pid=123, started_at_iso=None, @@ -761,7 +762,7 @@ def test_build_snapshot_marks_running_legacy_daemon_incompatible( ), ) - snap = server.build_snapshot("s-testaaaa", global_root=tmp_path) + snap = project_state.build_snapshot("s-testaaaa", global_root=tmp_path) assert snap is not None assert snap["partial"] is True @@ -798,7 +799,7 @@ def _raise(*_args, **_kwargs): broken("request usage"), ) - snap = server.build_snapshot("s-testaaaa", global_root=tmp_path) + snap = project_state.build_snapshot("s-testaaaa", global_root=tmp_path) assert snap is not None assert snap["partial"] is True @@ -811,15 +812,15 @@ def _raise(*_args, **_kwargs): "session", "request_usage", } - repeated = server.build_snapshot("s-testaaaa", global_root=tmp_path) + repeated = project_state.build_snapshot("s-testaaaa", global_root=tmp_path) assert repeated is not None assert "usage" in {item["section"] for item in repeated["diagnostics"]} def test_server_reexports_project_state_read_api() -> None: - assert server.build_snapshot is project_state.build_snapshot - assert server.list_projects is project_state.list_projects - assert server.project_life_dir is project_state.project_life_dir + assert project_state.build_snapshot is project_state.build_snapshot + assert project_state.list_projects is project_state.list_projects + assert project_state.project_life_dir is project_state.project_life_dir def test_malformed_daemon_admission_is_visible_in_snapshot_diagnostics( @@ -831,7 +832,7 @@ def test_malformed_daemon_admission_is_visible_in_snapshot_diagnostics( encoding="utf-8", ) - snap = server.build_snapshot("s-testaaaa", global_root=tmp_path) + snap = project_state.build_snapshot("s-testaaaa", global_root=tmp_path) assert snap is not None assert snap["partial"] is True @@ -851,7 +852,7 @@ def test_daemon_backend_follows_engineer_role_not_stale_status(tmp_path: Path, m json.dumps({"pid": 999999, "backend": "codex", "started_at_iso": "2020-01-01T00:00:00Z"}), encoding="utf-8", ) - snap = server.build_snapshot("s-becons01", global_root=tmp_path) + snap = project_state.build_snapshot("s-becons01", global_root=tmp_path) assert snap is not None eng = next(r for r in snap["roles"] if r["role"] == "engineer") assert eng["backend"] == "copilot" # roles resolve live from the env knob @@ -861,7 +862,7 @@ def test_daemon_backend_follows_engineer_role_not_stale_status(tmp_path: Path, m def test_list_projects(tmp_path: Path) -> None: _make_project(tmp_path) - projects = server.list_projects(global_root=tmp_path) + projects = project_state.list_projects(global_root=tmp_path) ids = {p["id"] for p in projects} assert "s-testaaaa" in ids p = next(p for p in projects if p["id"] == "s-testaaaa") @@ -880,17 +881,17 @@ def test_list_projects_hides_empty_shells_and_caps(tmp_path: Path) -> None: (tmp_path / "projects" / "s-empty0000").mkdir(parents=True) # default hides the empty shell (picker shows real work, not litter) - ids = {p["id"] for p in server.list_projects(global_root=tmp_path)} + ids = {p["id"] for p in project_state.list_projects(global_root=tmp_path)} assert "s-empty0000" not in ids assert {"s-aaaa1111", "s-bbbb2222", "s-cccc3333"} <= ids # opt-in surfaces every dir assert "s-empty0000" in { - p["id"] for p in server.list_projects(global_root=tmp_path, include_empty=True) + p["id"] for p in project_state.list_projects(global_root=tmp_path, include_empty=True) } # limit bounds the per-item daemon-status reads - assert len(server.list_projects(global_root=tmp_path, limit=2)) == 2 + assert len(project_state.list_projects(global_root=tmp_path, limit=2)) == 2 def test_web_project_index_hides_legacy_internal_dirs(tmp_path: Path) -> None: diff --git a/tests/webapi/test_source_update.py b/tests/webapi/test_source_update.py index 1050838c7..7d83ddd46 100644 --- a/tests/webapi/test_source_update.py +++ b/tests/webapi/test_source_update.py @@ -37,10 +37,10 @@ def test_source_update_routes_are_authenticated_and_dispatch_jobs( tmp_path, monkeypatch, ) -> None: calls: list[str] = [] - monkeypatch.setattr(server, "read_source_update_status", lambda _root: _status()) + monkeypatch.setattr(source_update, 'read_source_update_status', lambda _root: _status()) monkeypatch.setattr( - server, - "start_source_update", + source_update, + 'start_source_update', lambda _root, *, action: calls.append(action) or _status( state="checking" if action == "check" else "updating", running=True, diff --git a/tests/webapi/test_wave1.py b/tests/webapi/test_wave1.py index 50e70525f..2f6d0e4da 100644 --- a/tests/webapi/test_wave1.py +++ b/tests/webapi/test_wave1.py @@ -16,13 +16,16 @@ import pytest from argus.core.session import SessionMeta, read_session_meta, write_session_meta +from argus.daemon import life_worker as daemon_worker from argus.life.memory import LifeMemory from argus.manager import config_intent, front_door from argus.webapi import ( artifacts, + daemon_lifecycle, manager_bridge, manager_pending_question, manager_state, + project_crud, server, ) @@ -88,7 +91,7 @@ def test_create_daemon_separates_launch_cwd_from_execution_workdir( ) -> None: launch = tmp_path / "workspace" launch.mkdir() - created = server.create_daemon("", launch_cwd=str(launch), global_root=tmp_path) + created = daemon_lifecycle.create_daemon("", launch_cwd=str(launch), global_root=tmp_path) meta = read_session_meta(tmp_path, created["sid"]) assert meta is not None assert meta.launch_cwd == str(launch.resolve()) @@ -102,7 +105,7 @@ def test_create_daemon_honours_explicit_execution_workdir(tmp_path: Path) -> Non launch.mkdir() workdir.mkdir() - created = server.create_daemon( + created = daemon_lifecycle.create_daemon( "", launch_cwd=str(launch), workdir=str(workdir), @@ -131,13 +134,13 @@ def test_create_daemon_rejects_an_unavailable_workdir_before_command_submission( def test_launch_cwd_update_preserves_existing_session_name(tmp_path: Path) -> None: - created = server.create_daemon(name="Existing name", global_root=tmp_path) + created = daemon_lifecycle.create_daemon(name="Existing name", global_root=tmp_path) original = read_session_meta(tmp_path, created["sid"]) assert original is not None launch = tmp_path / "new-workspace" launch.mkdir() - assert server.set_project_launch_cwd( + assert daemon_lifecycle.set_project_launch_cwd( created["sid"], str(launch), global_root=tmp_path, @@ -151,11 +154,11 @@ def test_launch_cwd_update_preserves_existing_session_name(tmp_path: Path) -> No def test_workdir_update_preserves_state_root_and_session_name(tmp_path: Path) -> None: - created = server.create_daemon(name="Existing name", global_root=tmp_path) + created = daemon_lifecycle.create_daemon(name="Existing name", global_root=tmp_path) workspace = tmp_path / "new-workspace" workspace.mkdir() - result = server.set_project_workdir( + result = daemon_lifecycle.set_project_workdir( created["sid"], str(workspace), global_root=tmp_path, @@ -177,7 +180,7 @@ def test_set_project_workdir_uses_pipeline_then_session_lock_order( tmp_path: Path, monkeypatch, ) -> None: - created = server.create_daemon(global_root=tmp_path) + created = daemon_lifecycle.create_daemon(global_root=tmp_path) workspace = tmp_path / "ordered-workspace" workspace.mkdir() order: list[str] = [] @@ -201,7 +204,7 @@ def session_lock(_root): session_lock, ) - result = server.set_project_workdir( + result = daemon_lifecycle.set_project_workdir( created["sid"], str(workspace), global_root=tmp_path, @@ -237,7 +240,7 @@ def test_set_project_workdir_claims_legacy_session(tmp_path: Path) -> None: life = _make_project(tmp_path, sid="s-legacy1") workspace = tmp_path / "workspace" workspace.mkdir() - result = server.set_project_workdir( + result = daemon_lifecycle.set_project_workdir( "s-legacy1", str(workspace), global_root=tmp_path, @@ -253,16 +256,16 @@ def test_set_project_workdir_rejects_live_daemon_change( tmp_path: Path, monkeypatch, ) -> None: - created = server.create_daemon(global_root=tmp_path) + created = daemon_lifecycle.create_daemon(global_root=tmp_path) workspace = tmp_path / "new-workspace" workspace.mkdir() monkeypatch.setattr( - server, - "read_daemon_status", + daemon_worker, + 'read_daemon_status', lambda _path: SimpleNamespace(alive=True, pid=123), ) - result = server.set_project_workdir( + result = daemon_lifecycle.set_project_workdir( created["sid"], str(workspace), global_root=tmp_path, @@ -280,18 +283,18 @@ def test_set_project_workdir_allows_live_idempotent_rebind( ) -> None: workspace = tmp_path / "workspace" workspace.mkdir() - created = server.create_daemon( + created = daemon_lifecycle.create_daemon( launch_cwd=str(workspace), workdir=str(workspace), global_root=tmp_path, ) monkeypatch.setattr( - server, - "read_daemon_status", + daemon_worker, + 'read_daemon_status', lambda _path: SimpleNamespace(alive=True, pid=123), ) - result = server.set_project_workdir( + result = daemon_lifecycle.set_project_workdir( created["sid"], str(workspace), global_root=tmp_path, @@ -310,10 +313,10 @@ def test_set_project_workdir_rejects_workspace_owned_by_other_daemon( ) -> None: workspace = tmp_path / "workspace" workspace.mkdir() - created = server.create_daemon(global_root=tmp_path) + created = daemon_lifecycle.create_daemon(global_root=tmp_path) monkeypatch.setattr( - server, - "_active_workspace_owner", + daemon_lifecycle, + '_active_workspace_owner', lambda *_args, **_kwargs: { "sid": "s-other", "pid": 456, @@ -321,7 +324,7 @@ def test_set_project_workdir_rejects_workspace_owned_by_other_daemon( }, ) - result = server.set_project_workdir( + result = daemon_lifecycle.set_project_workdir( created["sid"], str(workspace), global_root=tmp_path, @@ -442,7 +445,7 @@ def commit_vertical_decision(self, text, decision, **kwargs): monkeypatch.setattr(front_door, "_ensure_manager_runner", ensure) monkeypatch.setattr(config_intent, "_ensure_manager_runner", ensure) assert ( - server.set_continuous( + project_crud.set_continuous( sid, enabled=True, objective="Write the CO2 paper", diff --git a/tests/webapi/test_workspace_v2.py b/tests/webapi/test_workspace_v2.py index 512fce5e7..9abb0206b 100644 --- a/tests/webapi/test_workspace_v2.py +++ b/tests/webapi/test_workspace_v2.py @@ -10,7 +10,7 @@ from fastapi import HTTPException from fastapi.testclient import TestClient -from argus.webapi import server +from argus.webapi import daemon_lifecycle, server from argus.webapi.routes.workspace_v2 import _git, _open_confined_file, _workspace_profiles @@ -227,7 +227,7 @@ def test_final_review_uses_existing_request_id_without_content_hashes( manuscript.parent.mkdir(parents=True) manuscript.write_text("# Manuscript\n", encoding="utf-8") state = tmp_path / "state" - created = server.create_daemon(workdir=str(workspace), global_root=state) + created = daemon_lifecycle.create_daemon(workdir=str(workspace), global_root=state) sid = created["sid"] monkeypatch.setattr( "argus.webapi.mission_items.enqueue_task_command",