diff --git a/desktop/scripts/seed_safety.py b/desktop/scripts/seed_safety.py index e37024603..bf6a72a30 100644 --- a/desktop/scripts/seed_safety.py +++ b/desktop/scripts/seed_safety.py @@ -4,28 +4,54 @@ Pauses every task (``enabled = 0``: the scheduler and triggers skip it), turns off every paired chat's delivery switch and turns messaging apps off in config.yaml (``channels.enabled: false``), so no seeded task runs and nothing -is sent through Telegram, WhatsApp or Discord. The seed scripts call ``quiet()`` when they finish and -``scripts/smoke.mjs`` runs this file before it starts the app; both skip it with ``--keep-live``. +is sent through Telegram, WhatsApp or Discord. + +The engine plans every ``planning`` task and resumes every ``processing`` run when it starts, paused or not, so those +would still call the model (issue #388). A task still being planned is declined instead (Run again plans a fresh +copy when the user wants it), a run in progress is cancelled, and a task still marked as running is settled +(a routine goes back to waiting, anything else is cancelled). + +The seed scripts call ``quiet()`` when they finish and ``scripts/smoke.mjs`` runs this file before it starts the app; +both skip it with ``--keep-live``. It refuses the default data folder (``~/.sentient``): that is the real user's, never a demo. """ from __future__ import annotations +import json import os import sqlite3 import sys +from datetime import UTC, datetime from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[2] KEEP_LIVE_FLAG = "--keep-live" KEEP_LIVE_HELP = "keep seeded tasks active and messaging deliveries on (by default they are paused, see seed_safety.py)" +STOPPED_RUN = "Stopped so the demo home stays quiet." def _table_has(db: sqlite3.Connection, table: str, column: str) -> bool: return any(row[1] == column for row in db.execute(f"PRAGMA table_info({table})")) +def _settle_busy_tasks(db: sqlite3.Connection) -> None: + """Settle tasks left ``processing`` with no run in progress, as the engine would but without writing a missing + result (that asks the model). A routine goes back to waiting for its next time, anything else is cancelled.""" + busy = db.execute( + "SELECT id, schedule FROM tasks WHERE status = 'processing' AND NOT EXISTS (SELECT 1 FROM task_runs r " + "WHERE r.task_id = tasks.id AND r.status IN ('processing', 'waiting_for_user'))" + ).fetchall() + for task_id, schedule in busy: + try: + kind = (json.loads(schedule or "null") or {}).get("type") + except (ValueError, AttributeError): + kind = None + status = "active" if kind in {"recurring", "triggered"} else "cancelled" + db.execute("UPDATE tasks SET status = ?, next_execution_at = NULL WHERE id = ?", (status, task_id)) + + def quiet(home: str | Path) -> dict: """Pause the tasks and turn off channel deliveries in ``home``. Returns what changed.""" home = Path(home).expanduser().resolve() @@ -37,7 +63,7 @@ def quiet(home: str | Path) -> dict: if secrets.is_default_home(home): raise SystemExit(f"seed_safety: {home} is the default data folder (the real one); refusing to change it.") os.environ["SENTIENT_HOME"] = str(home) # paths.* read it on every call - report = {"tasks_paused": 0, "chats_muted": 0, "channels_off": False} + report = {"tasks_paused": 0, "plans_stopped": 0, "runs_stopped": 0, "chats_muted": 0, "channels_off": False} if paths.config_file().exists() or paths.db_file().exists(): cfg = load_config() if cfg.channels.enabled: @@ -50,6 +76,17 @@ def quiet(home: str | Path) -> dict: with db: if _table_has(db, "tasks", "enabled"): report["tasks_paused"] = db.execute("UPDATE tasks SET enabled = 0 WHERE enabled != 0").rowcount + if _table_has(db, "tasks", "status"): + report["plans_stopped"] = db.execute( + "UPDATE tasks SET status = 'declined', next_execution_at = NULL WHERE status = 'planning'" + ).rowcount + if _table_has(db, "task_runs", "status"): + report["runs_stopped"] = db.execute( + "UPDATE task_runs SET status = 'cancelled', error = ?, pending_question = NULL, " + "finished_at = ? WHERE status = 'processing'", + (STOPPED_RUN, datetime.now(UTC).isoformat(timespec="seconds")), + ).rowcount + _settle_busy_tasks(db) if _table_has(db, "channel_chats", "deliver"): report["chats_muted"] = db.execute( "UPDATE channel_chats SET deliver = 0 WHERE deliver != 0").rowcount @@ -59,7 +96,8 @@ def quiet(home: str | Path) -> dict: def announce(report: dict) -> None: - print(f"quiet demo home: {report['tasks_paused']} tasks paused, {report['chats_muted']} chat deliveries off, " + print(f"quiet demo home: {report['tasks_paused']} tasks paused, {report['plans_stopped']} plans and " + f"{report['runs_stopped']} runs stopped, {report['chats_muted']} chat deliveries off, " f"messaging apps {'turned off' if report['channels_off'] else 'already off'} " f"(pass {KEEP_LIVE_FLAG} to keep them live)") diff --git a/docs/DEVELOPING.md b/docs/DEVELOPING.md index 04906a888..ace9ae905 100644 --- a/docs/DEVELOPING.md +++ b/docs/DEVELOPING.md @@ -85,9 +85,10 @@ In Git Bash write smoke routes without a leading slash (`settings/models`), or M turns them into Windows paths. Set `SENTIENT_HOME` to a seeded profile to capture realistic screens. Demo data seeders live in `desktop/scripts/seed-*.py` (`seed-chats-devices-channels.py` for chat tool cards, devices, helpers and messaging channels; `seed-automations-usermodel.py` for About you, dreams, -watcher jobs and webhooks). When they finish, the seeders pause every task in the home and turn off messaging apps -and every paired chat's delivery (`desktop/scripts/seed_safety.py`), and `smoke.mjs` does the same before it starts -the app on a `SENTIENT_HOME`, so a demo never runs a task or messages anyone. Pass `--keep-live` to either to keep +watcher jobs and webhooks). When they finish, the seeders pause every task in the home, decline any task still +being planned, cancel any run in progress (the engine would plan or resume those with the model on start) and turn +off messaging apps and every paired chat's delivery (`desktop/scripts/seed_safety.py`), and `smoke.mjs` does the +same before it starts the app on a `SENTIENT_HOME`, so a demo never calls a model, runs a task or messages anyone. Pass `--keep-live` to either to keep them as seeded. `seed_safety.py` refuses the default `~/.sentient`. When several people build and capture screenshots at once, take `desktop/.build.lock` diff --git a/tests/test_keychain_namespace.py b/tests/test_keychain_namespace.py index 7a8ef816f..fd2838d14 100644 --- a/tests/test_keychain_namespace.py +++ b/tests/test_keychain_namespace.py @@ -190,7 +190,7 @@ async def test_seed_safety_pauses_tasks_and_channel_deliveries(config, isolated_ await app.stop() report = _seed_safety().quiet(isolated_home) - assert report == {"tasks_paused": 1, "chats_muted": 1, "channels_off": True} + assert report == {"tasks_paused": 1, "plans_stopped": 0, "runs_stopped": 0, "chats_muted": 1, "channels_off": True} assert load_config().channels.enabled is False app = await SentientApp(load_config(), llm=FakeProvider(), enable_background=False).start() try: @@ -200,6 +200,75 @@ async def test_seed_safety_pauses_tasks_and_channel_deliveries(config, isolated_ await app.stop() +class NoModel(FakeProvider): + """A model that must never be called: every call is recorded and fails.""" + + def __init__(self) -> None: + super().__init__() + self.used: list[str] = [] + + async def stream(self, role, messages, tools=None, *, model=None): + self.used.append(f"stream:{role}") + raise RuntimeError("seed_safety let a model call through") + yield # pragma: no cover + + async def complete_text(self, role, messages, *, model=None): + self.used.append(f"text:{role}") + raise RuntimeError("seed_safety let a model call through") + + async def complete_json(self, role, messages, *, model=None): + self.used.append(f"json:{role}") + raise RuntimeError("seed_safety let a model call through") + + async def embed(self, texts, *, model=None): + self.used.append("embed") + raise RuntimeError("seed_safety let a model call through") + + +async def test_seed_safety_keeps_planning_tasks_and_live_runs_from_calling_the_model(config, isolated_home, user_home): + """#388: the engine plans 'planning' tasks and resumes 'processing' runs on start even when they are paused.""" + from sentient.config.loader import load_config, save_config + + save_config(config) + now = "2026-10-01T00:00:00+00:00" + app = await SentientApp(config, llm=FakeProvider(), enable_background=False).start() + try: + repo = app.tasks.repo + await repo.insert_task({"id": "demo-planning", "name": "Find gift ideas for Anika", "status": "planning", + "enabled": True, "plan": [], "original_prompt": "Find gift ideas for Anika", + "created_at": now, "updated_at": now}) + await repo.insert_task({"id": "demo-running", "name": "Collect onboarding examples", "status": "processing", + "enabled": True, "plan": [{"tool": "web", "description": "Read the pages"}], + "schedule": {"type": "once", "run_at": None, "timezone": "UTC"}, + "created_at": now, "updated_at": now}) + run_id = await repo.insert_run("demo-running", now=now, plan=[{"tool": "web", "description": "Read the pages"}]) + # finished but never settled, with no result: the engine would ask the model to write one on start + await repo.insert_task({"id": "demo-unsettled", "name": "Sort the inbox", "status": "processing", + "enabled": True, "plan": [{"tool": "gmail", "description": "Sort it"}], + "schedule": {"type": "recurring", "frequency": "daily", "time": "09:00"}, + "created_at": now, "updated_at": now}) + done = await repo.insert_run("demo-unsettled", now=now, plan=[]) + await repo.update_run(done, {"status": "completed", "finished_at": now}) + finally: + await app.stop() + + report = _seed_safety().quiet(isolated_home) + assert report["plans_stopped"] == 1 and report["runs_stopped"] == 1 + llm = NoModel() + app = await SentientApp(load_config(), llm=llm, enable_background=True).start() + try: + await app.tasks.drain(timeout=10) + assert llm.used == [] + planning = await app.tasks.repo.get_task("demo-planning") + assert (planning["status"], planning["enabled"]) == ("declined", False) + assert (await app.tasks.repo.get_task("demo-running"))["status"] == "cancelled" + run = await app.tasks.repo.get_run(run_id) + assert run["status"] == "cancelled" and run["finished_at"] + assert (await app.tasks.repo.get_task("demo-unsettled"))["status"] == "active" + finally: + await app.stop() + + def test_seed_safety_refuses_the_default_home(user_home): with pytest.raises(SystemExit): _seed_safety().quiet(user_home / ".sentient")