From aa147a92334836821ef58fcafed9250a8d70ba32 Mon Sep 17 00:00:00 2001 From: Oleksii Dolhov Date: Mon, 3 Aug 2026 11:08:27 +0300 Subject: [PATCH] fix(autonomy): stop the toggle from erasing per-schedule enabled intent (#1945) `set_autonomy_status_logic` looped `db.set_schedule_enabled(id, enabled)` over every schedule on the agent, unfiltered and in both directions, so the agent gate and the per-schedule `enabled` flag shared one write path and only one survived. The first toggle destroyed per-schedule intent: a schedule the owner deliberately disabled was silently re-armed on the next autonomy-on, and a template-authored `enabled: false` was erased the same way. Autonomy-off was a set-all, not a pause -- nothing remembered the prior state. Since a template can materialize up to 20 declared schedules at creation, one unrelated toggle could arm all of them at once. The toggle is now a gate: it writes only `agent_ownership.autonomy_enabled`. The scheduler's existing cron-only gate (`_execute_schedule_with_lock` -> `get_autonomy_enabled`) is unchanged and now carries the whole load, so an enabled schedule on a paused agent is a normal state -- skipped, no execution row, `next_run_at` projection advanced (#1472), labelled "Will not fire -- autonomy off" in the UI (#1796). Manual triggers still bypass autonomy. No schema change: the fix is the removal of a write, so there is no column to migrate on either track. Existing rows are never rewritten -- an agent already flattened to all-disabled by a pre-fix toggle stays that way (the erased intent is unrecoverable) and the response now says so instead of silently re-arming. The response drops `schedules_updated` -- a count of a write that no longer happens -- for `total_schedules`, `enabled_schedules` and a server-authored `message` that names the case (no schedules / all disabled / N of M will run). `AgentDetail.vue` renders that message; both stores return the counts. Tests: `test_1945_autonomy_preserves_schedule_intent.py` covers the AC5 off->on cycle, proves no schedule row is written at all (unchanged `updated_at`/`next_run_at`, which `set_schedule_enabled` would bump), pins the response contract, and source-pins the scheduler gate that now carries the load. Verified failing against the pre-fix service. `test_1557`'s "still suppresses proactive work" guard was rewritten to pin the gate write and forbid the fan-out. Related to #1945 Co-Authored-By: Claude Opus 5 (1M context) --- docs/memory/architecture.md | 2 +- docs/memory/feature-flows/agent-network.md | 4 +- .../agents-page-ui-improvements.md | 5 +- docs/memory/feature-flows/autonomy-mode.md | 106 ++++---- .../autonomy-toggle-component.md | 13 +- docs/memory/requirements/scheduling.md | 17 +- docs/user-docs/agents/agent-configuration.md | 5 +- docs/user-docs/automation/scheduling.md | 4 +- docs/user-docs/getting-started/overview.md | 2 +- .../services/agent_service/autonomy.py | 105 +++++--- src/frontend/src/stores/agents.js | 6 +- src/frontend/src/stores/network.js | 6 +- src/frontend/src/views/AgentDetail.vue | 8 +- .../test_1557_autonomy_breaker_decoupled.py | 26 +- ...1945_autonomy_preserves_schedule_intent.py | 248 ++++++++++++++++++ 15 files changed, 445 insertions(+), 112 deletions(-) create mode 100644 tests/unit/test_1945_autonomy_preserves_schedule_intent.py diff --git a/docs/memory/architecture.md b/docs/memory/architecture.md index 042dcda49..ba995b122 100644 --- a/docs/memory/architecture.md +++ b/docs/memory/architecture.md @@ -848,7 +848,7 @@ Full flow: [cornelius-default-agent.md](feature-flows/cornelius-default-agent.md | GET/PUT | `/api/agents/{name}/folders` | Get/update shared folder config | | GET | `/api/agents/{name}/folders/available` | Mountable folders from permitted agents | | GET | `/api/agents/{name}/folders/consumers` | Agents that will mount this folder | -| GET/PUT | `/api/agents/{name}/autonomy` | Get / enable-disable autonomy (toggles all schedules) | +| GET/PUT | `/api/agents/{name}/autonomy` | Get / enable-disable autonomy — the agent-level gate the scheduler checks on every cron fire. **Writes only `agent_ownership.autonomy_enabled`**; per-schedule `enabled` is owner intent and is never rewritten, so an off→on cycle restores the prior per-schedule state (#1945). Response: `total_schedules`/`enabled_schedules`/`message` | | POST | `/api/agents/{name}/ssh-access` | Ephemeral **key-based** SSH credentials (admin-only; BYOK — the caller supplies `public_key`, the server never handles private keys #175). `auth_method` accepts only `"key"`; password auth returned 400 since #1615 (it never worked — agent sshd runs `PasswordAuthentication no`, and host-side hashing used the `crypt` module removed in Python 3.13) | | GET/PUT | `/api/agents/{name}/read-only` | Read-only mode status / toggle (blocks source file writes) | | GET/PUT | `/api/agents/{name}/timeout` | Execution timeout (60–7200s, default 3600s, #665). PUT 400 `agent_timeout_below_active_schedules` if the new cap drops below any non-deleted schedule's `timeout_seconds` (#929) | diff --git a/docs/memory/feature-flows/agent-network.md b/docs/memory/feature-flows/agent-network.md index 046bbfb94..e9f8f1d41 100644 --- a/docs/memory/feature-flows/agent-network.md +++ b/docs/memory/feature-flows/agent-network.md @@ -626,7 +626,9 @@ async function toggleAutonomy(agentName) { const response = await axios.put(`/api/agents/${agentName}/autonomy`, { enabled: newState }) node.data.autonomy_enabled = newState // Update reactively - return { success: true, enabled: newState, schedulesUpdated: response.data.schedules_updated } + // #1945: the toggle changes no schedule row — these are counts of what will run + return { success: true, enabled: newState, totalSchedules: response.data.total_schedules, + enabledSchedules: response.data.enabled_schedules, message: response.data.message } } ``` Toggles autonomy mode for an agent and updates the node data reactively. diff --git a/docs/memory/feature-flows/agents-page-ui-improvements.md b/docs/memory/feature-flows/agents-page-ui-improvements.md index 7208c03a9..d0ed7a947 100644 --- a/docs/memory/feature-flows/agents-page-ui-improvements.md +++ b/docs/memory/feature-flows/agents-page-ui-improvements.md @@ -568,9 +568,8 @@ Major UI overhaul to align Agents page with Dashboard (AgentNode.vue) tiles. Cha 2. **PUT /api/agents/{name}/autonomy** (`agents.py` lines 775-790) - Body: `{ enabled: boolean }` - - When enabled: activates all schedules for the agent - - When disabled: pauses all schedules - - Response: `{ enabled, schedules_updated }` + - Writes ONLY `agent_ownership.autonomy_enabled` — the agent-level gate the scheduler reads on every cron fire. Per-schedule `enabled` is never rewritten (#1945) + - Response: `{ autonomy_enabled, total_schedules, enabled_schedules, message }` 3. **GET /api/agents/context-stats** (`agents.py` lines 138-141) - Returns context window usage and activity state for all agents diff --git a/docs/memory/feature-flows/autonomy-mode.md b/docs/memory/feature-flows/autonomy-mode.md index be13f9765..ee4a37516 100644 --- a/docs/memory/feature-flows/autonomy-mode.md +++ b/docs/memory/feature-flows/autonomy-mode.md @@ -1,13 +1,27 @@ # Feature: Autonomy Mode -> **Last Updated**: 2026-02-22 - Dashboard visual indication: AgentNode now shows "(paused)" text next to schedule count when autonomy is disabled, providing immediate visual feedback that schedules are not executing. +> **Last Updated**: 2026-08-03 (#1945) — the toggle is a **gate, not a bulk edit**: it writes only `agent_ownership.autonomy_enabled` and no longer rewrites per-schedule `enabled`. Per-schedule intent now survives an off→on cycle. > -> **Previous (2026-02-12)** - UI Standardization: New `AutonomyToggle.vue` reusable component used in 4 locations. Running and Autonomy toggles now on same row in Dashboard and Agents page. +> **Previous (2026-02-22)** - Dashboard visual indication: AgentNode now shows "(paused)" text next to schedule count when autonomy is disabled, providing immediate visual feedback that schedules are not executing. ## Overview -Autonomy Mode enables or disables all scheduled tasks for an agent with a single toggle. When autonomy is enabled, all schedules run automatically. When disabled, all schedules are paused. +Autonomy Mode is the agent-level master gate for proactive work. When autonomy is enabled, the agent's **enabled** schedules run automatically. When disabled, no cron trigger fires for the agent at all. -**Scope (autonomy governs proactive work ONLY, #1557):** the toggle acts solely by enabling/disabling the agent's schedules (`db.set_schedule_enabled`). It deliberately does **not** touch the transport circuit breaker or any inbound path — a paused agent still answers manual chat, Telegram/Slack/public, and webhooks normally. An earlier hook (#631 AC#5) forced the transport breaker `dormant` on autonomy-off; because the `execute_task` gate consults that breaker for every trigger, it fast-failed all inbound chat on a healthy paused agent with "circuit breaker open — agent is unhealthy". That coupling was removed — see [dispatch-circuit-breaker.md](dispatch-circuit-breaker.md). #631's flood protection does not depend on it (the breaker's own failure-driven backoff/dormant path plus the #1464 leader lock and #1121 monitoring-default-off throttle a genuinely-down agent). +**Gate, not a bulk edit (#1945).** The toggle writes exactly one row — `agent_ownership.autonomy_enabled` — and never touches a schedule's own `enabled` flag. The two are different concepts: + +| Flag | Written by | Means | +|------|-----------|-------| +| `agent_ownership.autonomy_enabled` | the autonomy toggle | may this agent do proactive work *at all* | +| `agent_schedules.enabled` | the owner (Schedules tab / API / template) | should *this* schedule run while the agent is autonomous | + +Until #1945 both were written by the toggle: `set_autonomy_status_logic` looped `set_schedule_enabled(id, enabled)` over every schedule, unfiltered and in both directions, so the first toggle destroyed per-schedule intent — a deliberately-disabled schedule was silently re-armed on the next autonomy-on, and autonomy-off was a set-all rather than a pause. With a template able to materialize up to 20 declared schedules at agent creation, one unrelated toggle could arm all of them at once. + +Consequences of the gate model: +- An enabled schedule on a paused agent is a **normal, expected state**. The scheduler skips it (cron-only gate), writes no execution row, and advances its `next_run_at` projection so the UI never shows a receding "Next: Nd ago" (#1472). The Schedules tab labels it "Will not fire — autonomy off" and offers a one-click enable-autonomy banner (#1796). +- Re-enabling autonomy restores exactly the per-schedule state the owner left, disabled ones included. **Upgrade note:** an agent whose schedules were already flattened to disabled by a pre-#1945 autonomy-off stays that way — nothing re-arms them, and the toggle response says so ("all N schedule(s) are disabled — nothing will run until you enable one"). +- Admin fleet ops (`POST /api/ops/schedules/pause|resume`, `emergency_stop`) still write `enabled` in bulk by design — those are explicit set-all incident tools, not a per-agent gate. + +**Scope (autonomy governs proactive work ONLY, #1557):** the toggle deliberately does **not** touch the transport circuit breaker or any inbound path — a paused agent still answers manual chat, Telegram/Slack/public, and webhooks normally. An earlier hook (#631 AC#5) forced the transport breaker `dormant` on autonomy-off; because the `execute_task` gate consults that breaker for every trigger, it fast-failed all inbound chat on a healthy paused agent with "circuit breaker open — agent is unhealthy". That coupling was removed — see [dispatch-circuit-breaker.md](dispatch-circuit-breaker.md). #631's flood protection does not depend on it (the breaker's own failure-driven backoff/dormant path plus the #1464 leader lock and #1121 monitoring-default-off throttle a genuinely-down agent). ## User Story As an agent owner, I want to toggle autonomous operation for my agent so that I can quickly enable or disable all scheduled tasks without managing each schedule individually. @@ -162,7 +176,10 @@ async function toggleAutonomy(agentName) { return { success: true, enabled: newState, - schedulesUpdated: response.data.schedules_updated + // #1945: counts, not "how many we changed" — the toggle changes none + totalSchedules: response.data.total_schedules, + enabledSchedules: response.data.enabled_schedules, + message: response.data.message } } catch (error) { console.error('[Network] Failed to toggle autonomy:', error) @@ -227,10 +244,10 @@ async function toggleAutonomy() { // Update local state agent.value.autonomy_enabled = newState + // #1945: the server authors this line — the toggle gates schedules rather + // than activating them, and the message names the case the raw count hid. showNotification( - newState - ? `Autonomy enabled. ${result.schedules_updated} schedule(s) activated.` - : `Autonomy disabled. ${result.schedules_updated} schedule(s) paused.`, + result.message || `Autonomy ${newState ? 'enabled' : 'disabled'}.`, 'success' ) } catch (error) { @@ -377,34 +394,28 @@ async def set_autonomy_status_logic( enabled = bool(enabled) - # Update the autonomy flag + # The ONLY write (#1945). Do NOT re-add a per-schedule fan-out here — the + # per-schedule `enabled` flag is owner intent and must survive a toggle. db.set_autonomy_enabled(agent_name, enabled) - # Enable/disable all schedules for this agent - # NOTE (2026-02-11): Now uses database-only updates. Dedicated scheduler syncs within 60s. + # Report-only: what the agent's schedules will do under the new gate. schedules = db.list_agent_schedules(agent_name) - updated_count = 0 - for schedule in schedules: - schedule_id = schedule.id - if schedule_id: - db.set_schedule_enabled(schedule_id, enabled) # Also recalculates next_run_at - updated_count += 1 - - logger.info( - f"Autonomy {'enabled' if enabled else 'disabled'} for agent {agent_name} " - f"by {current_user.username}. Updated {updated_count} schedules." - ) + total_schedules = len(schedules) + enabled_schedules = sum(1 for s in schedules if s.enabled) + # message names the case: no schedules / all disabled / N of M will run + ... return { "status": "updated", "agent_name": agent_name, "autonomy_enabled": enabled, - "schedules_updated": updated_count, - "message": f"Autonomy {'enabled' if enabled else 'disabled'}. {updated_count} schedule(s) updated." + "total_schedules": total_schedules, + "enabled_schedules": enabled_schedules, + "message": message, } ``` -> **Note (2026-02-11)**: The service now uses `db.set_schedule_enabled()` directly since the embedded scheduler has been removed. The dedicated scheduler (`src/scheduler/`) automatically syncs schedule state changes from the database every 60 seconds. +> **Note (2026-08-03, #1945)**: the pre-#1945 body looped `db.set_schedule_enabled(id, enabled)` over every schedule here and returned `schedules_updated`. Both are gone — the loop was the defect (it erased per-schedule intent), and the count described a write that no longer happens. The response now carries `total_schedules` + `enabled_schedules` and a server-authored `message` the UI renders verbatim. The dedicated scheduler (`src/scheduler/`) picks up genuine per-schedule changes on its 60s sync; the autonomy gate itself is read live at fire time, so a toggle takes effect immediately. #### Bulk Status Logic (lines 120-143) ```python @@ -515,21 +526,21 @@ def get_all_agents_autonomy_status(self) -> Dict[str, bool]: ## Side Effects -### Schedule Toggling -When autonomy is toggled, all schedules for the agent are enabled/disabled in the database: - -> **Note (2026-02-11)**: The embedded scheduler service has been removed. Schedule state changes are now detected by the dedicated scheduler via 60-second periodic sync. +### Schedule Toggling — none (#1945) +Toggling autonomy writes **no** schedule row. The single side effect is the +`agent_ownership.autonomy_enabled` flag; every schedule keeps its own `enabled`, +`next_run_at` and `updated_at` untouched (a test pins the unchanged +`updated_at`/`next_run_at` pair, since `set_schedule_enabled` would bump both). ```python -schedules = db.list_agent_schedules(agent_name) -for schedule in schedules: - if enabled: - db.set_schedule_enabled(schedule.id, True) # Also recalculates next_run_at - else: - db.set_schedule_enabled(schedule.id, False) # Clears next_run_at -# Dedicated scheduler syncs changes within 60 seconds +db.set_autonomy_enabled(agent_name, enabled) # the only write +schedules = db.list_agent_schedules(agent_name) # read-only, for the response counts ``` +Because nothing changes per schedule, the scheduler's 60s sync has nothing to +pick up — the gate is read live on every cron fire, so the pause/resume is +immediate. + ### Scheduler Enforcement The **dedicated scheduler service** double-checks autonomy before executing any schedule: @@ -645,8 +656,9 @@ Response: "status": "updated", "agent_name": "my-agent", "autonomy_enabled": true, - "schedules_updated": 3, - "message": "Autonomy enabled. 3 schedule(s) updated." + "total_schedules": 3, + "enabled_schedules": 2, + "message": "Autonomy enabled. 2 of 3 schedule(s) will run; per-schedule settings unchanged." } ``` @@ -667,20 +679,21 @@ Response: - Verify: Click toggle - switch slides right, label changes to "AUTO" (amber) - Verify: Click again - switch slides left, returns to "Manual" -2. **Dashboard Toggle Immediate Effect** - - Action: Toggle autonomy on an agent with schedules - - Expected: Schedules are immediately enabled/disabled in the backend - - Verify: Open Agent Detail -> Schedules tab, confirm schedule states match +2. **Per-schedule intent survives a toggle (#1945)** — the regression scenario + - Action: on an agent with two enabled schedules, disable ONE from the Schedules tab, then toggle autonomy off and back on + - Expected: the disabled schedule is still disabled; the other is still enabled + - Verify: Schedules tab shows one Active + one Disabled; `sqlite3 ~/trinity-data/trinity.db "SELECT id, enabled FROM agent_schedules WHERE agent_name=''"` matches + - Automated: `tests/unit/test_1945_autonomy_preserves_schedule_intent.py` 3. **Toggle from Agent Detail** - Action: Open agent detail page, click "Manual" button - - Expected: Button changes to "AUTO", success notification shows schedule count + - Expected: Button changes to "AUTO", notification names the case ("N of M schedule(s) will run", or "all N are disabled — nothing will run") - Verify: Refresh page - state persists 4. **Disable Autonomy** - Action: Click "AUTO" button on an agent with autonomy enabled - - Expected: Button changes to "Manual", schedules paused notification - - Verify: Check Schedules tab - all schedules should be disabled + - Expected: Button changes to "Manual", "N schedule(s) paused; per-schedule settings preserved" + - Verify: Schedules tab still shows each schedule's own Active/Disabled state (unchanged), with the "Will not fire — autonomy off" warning on the enabled ones; nothing fires 5. **System Agent Exclusion** - Action: Navigate to trinity-system agent @@ -699,7 +712,7 @@ Response: ## Related Flows -- **Upstream**: [Scheduling](scheduling.md) - Autonomy controls schedule enabled/disabled state +- **Upstream**: [Scheduling](scheduling.md) - Autonomy gates whether an enabled schedule may fire (it does not change the schedule's own `enabled`, #1945) - **Related**: [Scheduler Service](scheduler-service.md) - Dedicated scheduler enforces autonomy check before execution - **Related**: [Agent Lifecycle](agent-lifecycle.md) - Agent must exist for autonomy to apply - **Related**: [Agent Sharing](agent-sharing.md) - Shares `can_share` permission check for toggle access @@ -710,6 +723,7 @@ Response: | Date | Change | |------|--------| +| 2026-08-03 | **Gate, not a bulk edit (#1945)**: `set_autonomy_status_logic` no longer loops `set_schedule_enabled` over every schedule — it writes only `agent_ownership.autonomy_enabled`, and the scheduler's existing cron-fire gate (`src/scheduler/service.py::_execute_schedule_with_lock`) does the rest. Per-schedule `enabled` is now purely owner intent and survives an autonomy off→on cycle in both directions; a template-authored `enabled: false` is likewise no longer erased. Response drops `schedules_updated` (a count of a write that no longer happens) for `total_schedules` + `enabled_schedules` + a server-authored `message`; `AgentDetail.vue` renders the message verbatim. No schema change and no migration — the fix is the removal of a write. Upgrade note: an agent already flattened to all-disabled by a pre-#1945 toggle stays that way (the intent is unrecoverable), and the response says so. | | 2026-07-10 | **Decoupled from the circuit breaker (#1557)**: removed the #631 AC#5 hook that forced the transport circuit breaker `dormant` on autonomy-off (and reset it on autonomy-on). Pausing autonomy no longer blocks inbound chat — it acts only via `set_schedule_enabled`. The misleading "agent is unhealthy" fast-fail message was also split to name the real cause (transport-unreachable vs dispatch-auth-dead). See `services/agent_service/autonomy.py` and `services/task_execution_service.py::_circuit_breaker_error`. | | 2026-02-22 | **Dashboard Visual Indication**: AgentNode.vue (lines 138-155) now shows "(paused)" text in italics next to schedule count when autonomy is disabled. Schedule stats row grayed out (text-gray-300) when autonomy off vs normal gray (text-gray-500) when on. Schedule count fetched via `/api/agents/execution-stats` which now includes `schedules_total` and `schedules_enabled` fields. | | 2026-02-12 | **UI Standardization**: Extracted `AutonomyToggle.vue` reusable component (151 lines) used in 4 locations: AgentNode.vue, ReplayTimeline.vue, AgentHeader.vue, Agents.vue. Running and Autonomy toggles now on same row in Dashboard Graph (AgentNode.vue:57-86) and Agents page (Agents.vue:108-123). Created dedicated [autonomy-toggle-component.md](autonomy-toggle-component.md) for component documentation. | diff --git a/docs/memory/feature-flows/autonomy-toggle-component.md b/docs/memory/feature-flows/autonomy-toggle-component.md index 6d6b6de33..926f9d246 100644 --- a/docs/memory/feature-flows/autonomy-toggle-component.md +++ b/docs/memory/feature-flows/autonomy-toggle-component.md @@ -266,12 +266,10 @@ Store action (network.js/agents.js toggleAutonomy) PUT /api/agents/{name}/autonomy | v -Backend updates agent_ownership.autonomy_enabled - | - +-- Enable/disable all schedules for agent - | +Backend updates agent_ownership.autonomy_enabled (#1945: the ONLY write — + | per-schedule `enabled` is untouched) v -Response: { enabled, schedules_updated } +Response: { autonomy_enabled, total_schedules, enabled_schedules, message } | v Update local state (reactive) @@ -290,8 +288,9 @@ Response: { status: "updated", agent_name: "my-agent", autonomy_enabled: true, - schedules_updated: 3, - message: "Autonomy enabled. 3 schedule(s) updated." + total_schedules: 3, + enabled_schedules: 2, + message: "Autonomy enabled. 2 of 3 schedule(s) will run; per-schedule settings unchanged." } ``` diff --git a/docs/memory/requirements/scheduling.md b/docs/memory/requirements/scheduling.md index 0330377a3..610754edd 100644 --- a/docs/memory/requirements/scheduling.md +++ b/docs/memory/requirements/scheduling.md @@ -13,11 +13,22 @@ - **Flow**: `docs/memory/feature-flows/scheduling.md` ### 10.2 Autonomy Mode -- **Status**: ✅ Implemented (2026-01-01) -- **Description**: Master toggle for agent autonomous operation -- **Key Features**: Dashboard toggle, enables/disables all schedules +- **Status**: ✅ Implemented (2026-01-01); gate semantics corrected 2026-08-03 (#1945) +- **Description**: Master gate for agent autonomous operation +- **Key Features**: Dashboard toggle; gates every cron fire for the agent - **Flow**: `docs/memory/feature-flows/autonomy-mode.md` +#### 10.2.1 Autonomy is a Gate, Not a Bulk Edit (#1945) +- **Status**: ✅ Implemented (2026-08-03) +- **GitHub Issue**: #1945 +- **Description**: `set_autonomy_status_logic` used to loop `db.set_schedule_enabled(id, enabled)` over every schedule on the agent, unfiltered and in both directions — so the agent-level gate and the per-schedule `enabled` flag shared one write path and only one survived. The first toggle destroyed per-schedule intent: an owner-disabled (or template-authored `enabled: false`) schedule was silently re-armed on the next autonomy-on, and autonomy-off was a set-all rather than a pause. Since a template can materialize up to 20 declared schedules at creation, one unrelated toggle could arm all of them at once (LLM cost amplification). +- **Requirement**: the autonomy toggle MUST write only `agent_ownership.autonomy_enabled`. Per-schedule `enabled` is owner intent — nothing may rewrite it except an explicit per-schedule change (Schedules tab / `POST .../schedules/{id}/enable|disable` / `update_schedule`) or an explicit admin fleet op (`/api/ops/schedules/pause|resume`, `emergency_stop`). +- **Enforcement**: the scheduler's cron-only gate (`src/scheduler/service.py::_execute_schedule_with_lock` → `get_autonomy_enabled`) is authoritative and unchanged — it now carries the whole load, so an enabled schedule on a paused agent is a normal state: skipped, no execution row, `next_run_at` projection advanced (#1472), shown in the UI as "Will not fire — autonomy off" (#1796). A manual trigger still bypasses autonomy by design. +- **No schema change**: the fix is the removal of a write — no new column, no migration, nothing to keep in dual track. +- **Upgrade behavior**: existing rows are never rewritten. An agent already flattened to all-disabled by a pre-#1945 toggle stays that way (the erased intent is unrecoverable); the toggle response says so explicitly instead of silently re-arming. +- **API**: `PUT /api/agents/{name}/autonomy` drops `schedules_updated` (a count of a write that no longer happens) in favor of `total_schedules`, `enabled_schedules`, and a server-authored `message`. +- **Tests**: `tests/unit/test_1945_autonomy_preserves_schedule_intent.py` (AC5 off→on cycle, no-write proof via unchanged `updated_at`/`next_run_at`, response contract, scheduler-gate source pin); `tests/unit/test_1557_autonomy_breaker_decoupled.py` updated — its "still suppresses proactive work" guard now pins the gate write and forbids the fan-out. + ### 10.3 Execution Queue - **Status**: ✅ Implemented - **Description**: Redis-based queue preventing parallel execution conflicts diff --git a/docs/user-docs/agents/agent-configuration.md b/docs/user-docs/agents/agent-configuration.md index ac6c8ee95..046695e83 100644 --- a/docs/user-docs/agents/agent-configuration.md +++ b/docs/user-docs/agents/agent-configuration.md @@ -20,10 +20,11 @@ How many tasks the agent may run concurrently (`max_parallel_tasks`, default 3). ### Autonomy Mode -Master toggle that enables or disables all scheduled operations for an agent. +Master gate for the agent's scheduled operations. - Toggle from the Dashboard, Agents page, or Agent Detail view -- When disabled, all schedules for that agent are paused +- When disabled, none of the agent's schedules fire +- The toggle does **not** change each schedule's own on/off switch: a schedule you disabled stays disabled when you turn autonomy back on, and one you left enabled resumes automatically - API: `GET /api/agents/{name}/autonomy` and `PUT /api/agents/{name}/autonomy` ### Read-Only Mode diff --git a/docs/user-docs/automation/scheduling.md b/docs/user-docs/automation/scheduling.md index 959a162d3..abcd89377 100644 --- a/docs/user-docs/automation/scheduling.md +++ b/docs/user-docs/automation/scheduling.md @@ -10,7 +10,7 @@ For a single, agent-initiated, one-shot deferred follow-up rather than a recurri - **Schedule** -- A cron expression paired with a message or task sent to an agent at the specified times. - **Execution** -- Each time a schedule fires, it creates an execution record with status, duration, response, cost, and model used. -- **Autonomy Mode** -- Master toggle that enables or disables all schedules for an agent. Schedules will not fire if autonomy is off. +- **Autonomy Mode** -- Master gate for an agent's schedules: nothing fires while autonomy is off. It is a gate, not a bulk edit -- it never changes each schedule's own on/off switch, so turning autonomy off and back on restores exactly the schedules you had enabled. - **Scheduler Service** -- Standalone service with Redis distributed locks. Uses async fire-and-forget dispatch with DB polling for status. - **Misfire Handling** -- If the scheduler restarts, missed jobs within a 1-hour grace window are caught up and fired immediately (`misfire_grace_time=3600`, `coalesce=True`, `max_instances=1`). @@ -25,7 +25,7 @@ For a single, agent-initiated, one-shot deferred follow-up rather than a recurri 5. Enable or disable individual schedules with the toggle. 6. View execution history with status, duration, and cost. 7. Click **Run Now** to trigger a schedule immediately. -8. Use the autonomy toggle to control all schedules at once. +8. Use the autonomy toggle to pause or resume all of the agent's scheduled work at once. Individual schedules keep their own enabled/disabled state across the toggle -- while autonomy is off, an enabled schedule shows a "Will not fire -- autonomy off" warning instead of being switched off. ### Execution Flow diff --git a/docs/user-docs/getting-started/overview.md b/docs/user-docs/getting-started/overview.md index 9bfc56654..858c058cb 100644 --- a/docs/user-docs/getting-started/overview.md +++ b/docs/user-docs/getting-started/overview.md @@ -30,7 +30,7 @@ Trinity runs as a set of Docker containers on your local machine or server. Afte 2. **Create an agent** -- From the dashboard, click "Create Agent" and select a template (GitHub repo URL or local path). Trinity pulls the template, builds a container, and deploys the agent. 3. **Configure credentials** -- Add API keys and secrets through the agent's credential panel. Credentials are encrypted in Redis and injected into the container at runtime with hot-reload support. 4. **Chat with the agent** -- Open the agent detail page and use the built-in chat interface. The agent processes your request using its configured tools, MCP connections, and reasoning context. -5. **Schedule autonomous work** -- Set up cron-based schedules so the agent executes tasks on its own. Enable Autonomy Mode to activate all schedules. +5. **Schedule autonomous work** -- Set up cron-based schedules so the agent executes tasks on its own. Enable Autonomy Mode to let the agent's enabled schedules run. 6. **Monitor the fleet** -- Use the dashboard to view agent health, execution history, and the network graph showing inter-agent communication. ## For Agents diff --git a/src/backend/services/agent_service/autonomy.py b/src/backend/services/agent_service/autonomy.py index 43747422e..6a3147dfa 100644 --- a/src/backend/services/agent_service/autonomy.py +++ b/src/backend/services/agent_service/autonomy.py @@ -1,9 +1,14 @@ """ Agent Service Autonomy - Autonomy mode management. -Handles agent autonomy mode toggle which enables/disables all scheduled tasks. -When autonomy is enabled, the agent's schedules run automatically. -When autonomy is disabled, schedules are paused. +Handles the agent autonomy mode toggle — the master gate for proactive work. +When autonomy is enabled, the agent's enabled schedules run automatically. +When autonomy is disabled, no cron trigger fires for the agent. + +The toggle is a GATE, not a bulk edit (#1945): it writes only +``agent_ownership.autonomy_enabled`` and never rewrites the per-schedule +``enabled`` flag, which is owner intent and must survive a toggle in both +directions. """ import logging from typing import Dict @@ -57,13 +62,25 @@ async def set_autonomy_status_logic( """ Set the autonomy status for an agent. - When enabling autonomy: - - All schedules for the agent are enabled - - The scheduler will pick them up automatically - - When disabling autonomy: - - All schedules for the agent are disabled - - No scheduled tasks will run until autonomy is re-enabled + Autonomy is a **gate, not a bulk edit** (#1945). The toggle writes exactly one + row — ``agent_ownership.autonomy_enabled`` — and never touches the per-schedule + ``enabled`` flag: + + - Autonomy off → the scheduler refuses to fire ANY cron trigger for this agent + (``src/scheduler/service.py`` ``_execute_schedule_with_lock``, cron-only gate). + An enabled schedule stays enabled and simply does not run; the scheduler + advances its ``next_run_at`` projection without recording an execution row + (#1472), and the Schedules tab labels it "Will not fire — autonomy off" (#1796). + - Autonomy on → schedules resume with exactly the ``enabled`` state their owner + left them in. A deliberately-disabled schedule stays disabled. + + Before #1945 this loop wrote ``set_schedule_enabled(id, enabled)`` over every + schedule on the agent, unfiltered and in both directions, so the first toggle + destroyed per-schedule intent: an owner-disabled (or template-authored + ``enabled: false``) schedule was silently re-armed on the next autonomy-on, and + autonomy-off was a set-all rather than a pause. With a template able to + materialize up to 20 declared schedules, one unrelated toggle could arm all of + them at once. Body: - enabled: True to enable autonomy, False to disable @@ -86,42 +103,62 @@ async def set_autonomy_status_logic( enabled = bool(enabled) - # Update the autonomy flag + # The ONLY write. The agent-level flag is the gate the scheduler consults on + # every cron fire, so it is sufficient on its own to start/stop proactive work. + # Do NOT re-add a per-schedule fan-out here (#1945): the per-schedule `enabled` + # flag is owner intent and must survive an autonomy toggle in both directions. db.set_autonomy_enabled(agent_name, enabled) - # Enable/disable all schedules for this agent - # NOTE: Dedicated scheduler syncs from database automatically on next sync cycle + # #1557 — autonomy governs PROACTIVE work ONLY; it deliberately does NOT touch + # the circuit breaker. The old #631 AC#5 hook forced the *transport* breaker + # dormant on autonomy-off, conflating "administratively paused" with "transport + # unhealthy": the execute_task gate consults the transport breaker for every + # trigger, so a healthy paused agent fast-failed all inbound chat + # (manual/Telegram/Slack/public) with "circuit breaker open — agent is + # unhealthy". #631's flood protection does not depend on this hook — the + # breaker's own failure-driven backoff/dormant path (fed by the pollers' real + # probes), plus the #1464 leader lock and #1121 monitoring-default-off, already + # throttle a genuinely down agent. Do NOT re-add a breaker write here. + + # Report-only: what the operator's schedules will actually do under the new gate. schedules = db.list_agent_schedules(agent_name) - updated_count = 0 - for schedule in schedules: - schedule_id = schedule.id - if schedule_id: - db.set_schedule_enabled(schedule_id, enabled) - updated_count += 1 - - # #1557 — autonomy governs PROACTIVE work ONLY (the schedules disabled above); - # it deliberately does NOT touch the circuit breaker. The old #631 AC#5 hook - # forced the *transport* breaker dormant on autonomy-off, conflating - # "administratively paused" with "transport unhealthy": the execute_task gate - # consults the transport breaker for every trigger, so a healthy paused agent - # fast-failed all inbound chat (manual/Telegram/Slack/public) with - # "circuit breaker open — agent is unhealthy". #631's flood protection does not - # depend on this hook — the breaker's own failure-driven backoff/dormant path - # (fed by the pollers' real probes), plus the #1464 leader lock and #1121 - # monitoring-default-off, already throttle a genuinely down agent. Do NOT re-add - # a breaker write here; pause the agent's proactive work via its schedules. + total_schedules = len(schedules) + enabled_schedules = sum(1 for s in schedules if s.enabled) + + if enabled: + if total_schedules == 0: + message = "Autonomy enabled. This agent has no schedules." + elif enabled_schedules == 0: + message = ( + f"Autonomy enabled, but all {total_schedules} schedule(s) are disabled — " + "nothing will run until you enable one." + ) + else: + message = ( + f"Autonomy enabled. {enabled_schedules} of {total_schedules} " + "schedule(s) will run; per-schedule settings unchanged." + ) + elif total_schedules == 0: + message = "Autonomy disabled. This agent has no schedules." + else: + message = ( + f"Autonomy disabled. {total_schedules} schedule(s) paused; " + "per-schedule settings preserved." + ) logger.info( f"Autonomy {'enabled' if enabled else 'disabled'} for agent {agent_name} " - f"by {current_user.username}. Updated {updated_count} schedules." + f"by {current_user.username}. {enabled_schedules}/{total_schedules} schedule(s) " + f"enabled (per-schedule state untouched)." ) return { "status": "updated", "agent_name": agent_name, "autonomy_enabled": enabled, - "schedules_updated": updated_count, - "message": f"Autonomy {'enabled' if enabled else 'disabled'}. {updated_count} schedule(s) updated." + "total_schedules": total_schedules, + "enabled_schedules": enabled_schedules, + "message": message, } diff --git a/src/frontend/src/stores/agents.js b/src/frontend/src/stores/agents.js index 05dfa34c1..7f27d7f46 100644 --- a/src/frontend/src/stores/agents.js +++ b/src/frontend/src/stores/agents.js @@ -737,7 +737,11 @@ export const useAgentsStore = defineStore('agents', { return { success: true, enabled: newState, - schedulesUpdated: response.data.schedules_updated + // #1945: autonomy is a gate, not a bulk edit — it no longer rewrites + // per-schedule `enabled`. These are counts, not "how many we changed". + totalSchedules: response.data.total_schedules, + enabledSchedules: response.data.enabled_schedules, + message: response.data.message } } catch (error) { console.error('Failed to toggle autonomy:', error) diff --git a/src/frontend/src/stores/network.js b/src/frontend/src/stores/network.js index cde95cf84..d15492cd4 100644 --- a/src/frontend/src/stores/network.js +++ b/src/frontend/src/stores/network.js @@ -1724,7 +1724,11 @@ export const useNetworkStore = defineStore('network', () => { return { success: true, enabled: newState, - schedulesUpdated: response.data.schedules_updated + // #1945: the toggle no longer rewrites per-schedule `enabled` — these + // report what the agent's schedules will do under the new gate. + totalSchedules: response.data.total_schedules, + enabledSchedules: response.data.enabled_schedules, + message: response.data.message } } catch (error) { console.error('[Network] Failed to toggle autonomy:', error) diff --git a/src/frontend/src/views/AgentDetail.vue b/src/frontend/src/views/AgentDetail.vue index 51bf75bdb..1896bf763 100644 --- a/src/frontend/src/views/AgentDetail.vue +++ b/src/frontend/src/views/AgentDetail.vue @@ -558,10 +558,12 @@ async function toggleAutonomy() { // Update local state agent.value.autonomy_enabled = newState + // #1945: the server authors this line — the toggle no longer "activates" + // schedules, it gates them, and the message names the case (no schedules / + // all disabled / N of M will run) that the raw count used to hide. showNotification( - newState - ? `Autonomy enabled. ${result.schedules_updated} schedule(s) activated.` - : `Autonomy disabled. ${result.schedules_updated} schedule(s) paused.`, + result.message || + `Autonomy ${newState ? 'enabled' : 'disabled'}.`, 'success' ) } catch (error) { diff --git a/tests/unit/test_1557_autonomy_breaker_decoupled.py b/tests/unit/test_1557_autonomy_breaker_decoupled.py index cc751a00a..14aa8d4dd 100644 --- a/tests/unit/test_1557_autonomy_breaker_decoupled.py +++ b/tests/unit/test_1557_autonomy_breaker_decoupled.py @@ -11,9 +11,10 @@ Two guards here, both of which fail against the pre-#1557 source: 1. **Structural** — ``autonomy.py`` no longer writes the breaker, and still - disables schedules (the real, and only, proactive-suppression mechanism). - This is the direct regression guard: the old code contained - ``force_circuit_dormant`` and the test would fail on it. + writes the agent-level autonomy gate (since #1945 that flag alone is the + proactive-suppression mechanism; the old per-schedule fan-out was itself a + bug and is now forbidden here). This is the direct regression guard: the old + code contained ``force_circuit_dormant`` and the test would fail on it. 2. **Message honesty** — the fast-fail reason now names *which* breaker fired (transport = unreachable, dispatch = auth-dead) instead of a blanket @@ -55,10 +56,21 @@ def test_autonomy_toggle_never_resets_the_circuit_either(): assert "reset_circuit" not in _AUTONOMY_SRC -def test_autonomy_still_suppresses_proactive_work_via_schedules(): - """Guard against over-deletion: pausing must still disable schedules — that - is how proactive work is actually stopped, independent of the breaker.""" - assert "set_schedule_enabled" in _AUTONOMY_SRC +def test_autonomy_still_suppresses_proactive_work_via_the_agent_gate(): + """Guard against over-deletion: pausing must still stop proactive work. + + #1945 moved the mechanism rather than removing it — the toggle now writes + ONLY the agent-level flag, and the scheduler's cron-fire gate + (``src/scheduler/service.py::_execute_schedule_with_lock``) reads it. The + old fan-out over ``set_schedule_enabled`` was the bug: it erased + per-schedule owner intent in both directions. So the surviving guard is + that the gate is written and the fan-out is not reintroduced. + """ + assert "set_autonomy_enabled" in _AUTONOMY_SRC + assert "db.set_schedule_enabled" not in _AUTONOMY_SRC, ( + "autonomy must not rewrite per-schedule `enabled` — that erases owner " + "intent in both directions (#1945)" + ) # --------------------------------------------------------------------------- diff --git a/tests/unit/test_1945_autonomy_preserves_schedule_intent.py b/tests/unit/test_1945_autonomy_preserves_schedule_intent.py new file mode 100644 index 000000000..be39b1766 --- /dev/null +++ b/tests/unit/test_1945_autonomy_preserves_schedule_intent.py @@ -0,0 +1,248 @@ +""" +Regression for #1945 — the autonomy toggle must not erase per-schedule intent. + +``set_autonomy_status_logic`` used to loop over every schedule on the agent and +write ``set_schedule_enabled(id, enabled)``, unfiltered and in both directions. +That made the agent-level gate and the per-schedule ``enabled`` flag share one +write path, and only one of them survived: + +- a schedule the owner deliberately disabled was silently re-armed on the next + autonomy-on; +- a template-authored ``enabled: false`` was erased the same way; +- autonomy-off was a set-all, not a pause — nothing remembered the prior state. + +With a template able to materialize up to 20 declared schedules at creation, +one unrelated toggle could arm all of them at once. + +The fix keeps the two concepts separate: the toggle writes ONLY +``agent_ownership.autonomy_enabled``, and the scheduler's cron-fire gate reads +it. Per-schedule ``enabled`` is owner intent and is never touched. + +Backend-agnostic via ``db_harness`` (#300): the schedule + autonomy reads and +writes go through the active engine (SQLite, and PostgreSQL when +``TEST_POSTGRES_URL`` is set). Only the access check and the Docker container +lookup are stubbed — neither is under test here. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + + +_BACKEND = Path(__file__).resolve().parent.parent.parent / "src" / "backend" +_BACKEND_STR = str(_BACKEND) +while _BACKEND_STR in sys.path: + sys.path.remove(_BACKEND_STR) +sys.path.insert(0, _BACKEND_STR) + +from db_harness import ( # noqa: E402 + db_backend, + seed_agent, + seed_user, + run as _hrun, + scalar as _hscalar, +) + + +# Sibling tests stub `sys.modules["db."]` with importlib-loaded modules +# bound to *their* tmp DBs and never restore on teardown. Snapshot + pop any +# stale stubs so this file's imports re-resolve fresh, and restore on teardown +# so we don't pollute siblings either. (Precedent: test_schedule_soft_delete.) +_STUBBED_MODULE_NAMES = [ + "db.schedules", + "db.agents", + "db.users", +] + +AGENT = "agent-1" + + +@pytest.fixture(autouse=True) +def _restore_sys_modules(): + saved = {n: sys.modules.get(n) for n in _STUBBED_MODULE_NAMES} + for name in _STUBBED_MODULE_NAMES: + sys.modules.pop(name, None) + try: + yield + finally: + for name, value in saved.items(): + if value is None: + sys.modules.pop(name, None) + else: + sys.modules[name] = value + + +def _seed_schedule(sid: str, *, enabled: bool, next_run_at: str | None = None) -> None: + _hrun( + "INSERT INTO agent_schedules " + "(id, agent_name, name, cron_expression, message, enabled, timezone, " + " owner_id, created_at, updated_at, next_run_at) " + "VALUES (:id, :a, :nm, '0 0 * * *', 'hi', :en, 'UTC', 1, " + " '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z', :nr)", + id=sid, a=AGENT, nm=sid, en=1 if enabled else 0, nr=next_run_at, + ) + + +def _enabled(sid: str) -> bool: + return bool(_hscalar("SELECT enabled FROM agent_schedules WHERE id = :s", s=sid)) + + +def _row(sid: str, column: str): + return _hscalar(f"SELECT {column} FROM agent_schedules WHERE id = :s", s=sid) + + +@pytest.fixture +def autonomy_env(db_backend, monkeypatch): + """Live agent owned by ``owner``, with the auth + container checks stubbed. + + Returns the service module so tests call the real code path. + """ + try: + from services.agent_service import autonomy + from models import User + except ImportError: # pragma: no cover - backend venv required + pytest.skip("backend venv required") + + seed_user(1, "owner", "user") + seed_agent(AGENT, 1) + + monkeypatch.setattr(autonomy.db, "can_user_share_agent", lambda u, a: True) + monkeypatch.setattr(autonomy.db, "is_system_agent", lambda a: False) + monkeypatch.setattr( + autonomy, "get_agent_container", lambda a: type("C", (), {"status": "running"})() + ) + + return autonomy, User(id=1, username="owner", role="user") + + +async def _toggle(autonomy_env, enabled: bool) -> dict: + autonomy, user = autonomy_env + return await autonomy.set_autonomy_status_logic(AGENT, {"enabled": enabled}, user) + + +# --------------------------------------------------------------------------- +# AC5 — the scenario named in the issue +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_disabled_schedule_survives_an_autonomy_off_on_cycle(autonomy_env): + """Disable one of two schedules → autonomy off → on → it is STILL disabled. + + Against the pre-#1945 loop the second toggle re-enabled it. + """ + _seed_schedule("sched-on", enabled=True) + _seed_schedule("sched-off", enabled=False) + + await _toggle(autonomy_env, True) + await _toggle(autonomy_env, False) + await _toggle(autonomy_env, True) + + assert _enabled("sched-off") is False, ( + "an owner-disabled schedule was force-enabled by an autonomy toggle (#1945)" + ) + assert _enabled("sched-on") is True + + +@pytest.mark.asyncio +async def test_autonomy_off_does_not_flatten_enabled_schedules(autonomy_env): + """AC2 — disabling autonomy pauses, it does not rewrite intent.""" + _seed_schedule("sched-on", enabled=True) + _seed_schedule("sched-off", enabled=False) + + await _toggle(autonomy_env, False) + + assert _enabled("sched-on") is True + assert _enabled("sched-off") is False + + +@pytest.mark.asyncio +async def test_toggle_writes_no_schedule_row_at_all(autonomy_env): + """Stronger than the flag check: the rows are not written, period. + + ``set_schedule_enabled`` bumps ``updated_at`` (the column the scheduler's + sync loop diffs on, #420) and rewrites ``next_run_at`` — so an unchanged + pair proves the fan-out is gone rather than merely idempotent. + """ + _seed_schedule("sched-on", enabled=True, next_run_at="2026-01-02T00:00:00Z") + before = (_row("sched-on", "updated_at"), _row("sched-on", "next_run_at")) + + await _toggle(autonomy_env, False) + await _toggle(autonomy_env, True) + + assert (_row("sched-on", "updated_at"), _row("sched-on", "next_run_at")) == before + + +# --------------------------------------------------------------------------- +# AC3 — the agent-level gate stays authoritative, and is now the ONLY thing +# stopping a cron fire +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_toggle_still_moves_the_agent_gate(autonomy_env): + autonomy, _ = autonomy_env + _seed_schedule("sched-on", enabled=True) + + await _toggle(autonomy_env, True) + assert autonomy.db.get_autonomy_enabled(AGENT) is True + + await _toggle(autonomy_env, False) + assert autonomy.db.get_autonomy_enabled(AGENT) is False + + +def test_scheduler_still_gates_cron_fires_on_autonomy(): + """The gate carries the whole load now — pin it at the source. + + ``src/scheduler/service.py::_execute_schedule_with_lock`` refuses a + cron-triggered fire when the agent's autonomy is off. Before #1945 an + enabled schedule on a paused agent was a rarity (the toggle disabled them + all); now it is the normal paused state, so losing this check would fire + every schedule of every paused agent. + """ + src = ( + Path(__file__).resolve().parents[2] / "src" / "scheduler" / "service.py" + ).read_text(encoding="utf-8") + assert 'triggered_by == "schedule" and not self.db.get_autonomy_enabled(' in src + + +# --------------------------------------------------------------------------- +# Response honesty — the old `schedules_updated` count described a write that +# no longer happens; the replacement must name what the schedules will do. +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_response_reports_counts_not_a_bogus_update_tally(autonomy_env): + _seed_schedule("sched-on", enabled=True) + _seed_schedule("sched-off", enabled=False) + + result = await _toggle(autonomy_env, True) + + assert result["autonomy_enabled"] is True + assert result["total_schedules"] == 2 + assert result["enabled_schedules"] == 1 + assert "schedules_updated" not in result + assert "1 of 2" in result["message"] + + +@pytest.mark.asyncio +async def test_enabling_autonomy_with_every_schedule_disabled_says_so(autonomy_env): + """The upgrade case: an agent whose schedules were already flattened to + disabled by a pre-#1945 autonomy-off no longer silently re-arms — so the + operator must be told nothing will run.""" + _seed_schedule("sched-off", enabled=False) + + result = await _toggle(autonomy_env, True) + + assert result["enabled_schedules"] == 0 + assert "nothing will run" in result["message"] + + +@pytest.mark.asyncio +async def test_agent_with_no_schedules_reports_zeroes(autonomy_env): + result = await _toggle(autonomy_env, True) + + assert result["total_schedules"] == 0 + assert result["enabled_schedules"] == 0 + assert "no schedules" in result["message"].lower()