Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 42 additions & 4 deletions desktop/scripts/seed_safety.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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:
Expand All @@ -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'",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
(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
Expand All @@ -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)")

Expand Down
7 changes: 4 additions & 3 deletions docs/DEVELOPING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
71 changes: 70 additions & 1 deletion tests/test_keychain_namespace.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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")
Loading