Thanks to visit codestin.com
Credit goes to github.com

Skip to content

feat(ui): scheduled recurring tasks in Agent UI#1566

Merged
itomek merged 13 commits into
mainfrom
kalin/scheduled-tasks
Jun 11, 2026
Merged

feat(ui): scheduled recurring tasks in Agent UI#1566
itomek merged 13 commits into
mainfrom
kalin/scheduled-tasks

Conversation

@kovtcharov

Copy link
Copy Markdown
Collaborator

Why this matters

GAIA had no way to run anything on a schedule from the Agent UI — every recurring use case ("every morning at 9, summarize my inbox") needed external tooling. This adds a Schedule Manager to the UI: describe a schedule in natural language ("every 30m", "daily at 9pm", "every monday at 8am"), attach a prompt, and the agent runs it on time through the local LLM, with each schedule's full run history living in its own chat session. Salvaged from #517 and rebuilt on current main with fail-loudly hardening (malformed configs raise instead of silently never firing; the scheduler refuses to boot with lost tasks; no dry-run no-op path), a concurrency cap, and the same tunnel security gate the autonomous agent loop uses.

Scope notes: NL intervals only — cron syntax and heartbeat tiers stay with the autonomy-engine plan, and the gaia schedule CLI (#1371) remains a separate complementary surface (coordination comment posted there; store unification is a follow-up for whichever lands second).

Test plan

  • 54 scheduler unit tests (tests/unit/test_scheduler.py)
  • 18 REST API tests (tests/unit/test_scheduler_api.py)
  • 13 integration tests incl. restart persistence + full API lifecycle (tests/integration/test_scheduler_e2e.py)
  • 6 new ChatDatabase persistence tests
  • Full unit suite: 5491 passed, 0 new failures (6 pre-existing on main)
  • tsc --noEmit clean + frontend build
  • Manual golden path via Playwright: Clock button → panel → NL parse preview → create → countdown → pause/delete with confirm
  • Boot smoke test: /api/schedules CRUD round-trip on a live server
  • Live end-to-end run with Lemonade (create "every 1m" schedule, verify response lands in the schedule's session)

Ported from kalin/autonomous-agent-infra (#517) with fixes:
- ScheduleConfig.from_json raises on malformed/unknown-key JSON instead
  of silently returning a config that never fires
- Scheduler requires a callable executor (dry-run no-op path removed)
- _load_tasks propagates DB errors instead of booting with zero tasks;
  corrupt schedule_config rows fail the boot loudly
- All SQL moved behind public ChatDatabase methods (no _lock/_conn use)
- Concurrency semaphore (default 1) bounds simultaneous task runs
- Session-write failures log at ERROR with tracebacks
Scheduled runs are suspended while a public tunnel is active (same
posture as AgentLoop) unless GAIA_AUTONOMOUS_ALLOW_TUNNEL is set.
Per-run timeout configurable via GAIA_SCHEDULE_TIMEOUT (default 300s).
Clock button in the sidebar opens the schedule panel: natural-language
schedule input with live parse preview, create/pause/resume/delete with
confirm, run counts and next-run countdown.
Adapted from #517: real ChatDatabase instead of a private-API fake,
explicit executors everywhere. Dropped TestServerSchedulerWiring — it
guarded against the silent dry-run mode, which no longer exists (the
Scheduler now raises at construction without an executor).
@github-actions github-actions Bot added documentation Documentation changes tests Test changes electron Electron app changes labels Jun 10, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Review: Scheduled recurring tasks in Agent UI

Solid, well-tested feature that lands cleanly on current architecture — persistence, fail-loudly config handling, a concurrency cap, and the tunnel gate are all here, backed by 85+ tests across unit/API/integration tiers. No security or breaking-change concerns. The one thing worth addressing before merge: the tunnel "suspend" path doesn't suspend — it fails every run and inflates error_count, which contradicts the docs and pollutes each schedule's session history.

Issues

🟡 Tunnel-active runs fail loudly instead of suspending (src/gaia/ui/server.py:2872, surfaced in scheduler.py:_execute_task)

The executor raises RuntimeError("Scheduled run suspended…") when a tunnel is active. But _execute_task catches every exception, so each fire still increments run_count and error_count, stores an error row in schedule_results, and writes Error: Scheduled run suspended… as an assistant message to the schedule's session. For an every 1m schedule with the tunnel up overnight, that's hundreds of bogus error rows and session messages — and the card shows a climbing error count. The docs say runs are "suspended while mobile access is active," which is not what happens.

Recommend gating in the run loop before counting a run — skip and re-sleep rather than execute-and-fail. Sketch:

# in _run_loop, before await self._execute_task(task):
if self._tunnel_blocked():   # inject the same tunnel check the server uses
    logger.info("Schedule '%s' skipped: tunnel active", task.name)
    await asyncio.sleep(min(task.interval_seconds, 60))
    continue

That keeps the security guarantee (LLM never runs) without the error-count/history noise. The tunnel check currently lives in the server closure; passing a small predicate into Scheduler keeps the suspend logic where the counting happens.

🟡 No minimum-interval floor; fresh ChatAgent per run (scheduler.py:parse_schedule_input, server.py:_run)

every 1s parses and runs — and each run constructs a brand-new ChatAgent (server.py:2878-2887), which can trigger a model (re)load through Lemonade. A sub-minute schedule against a single-tenant local backend will keep the machine pegged with construction churn. max_concurrent_runs=1 prevents overlap but not the back-to-back hammering. Consider rejecting intervals below a sane floor (e.g. 30–60 s) in create_task with an actionable ValueError, and/or caching the agent across runs if fresh-context isn't strictly required.

Minor

🟢 Unused imports in the integration test (tests/integration/test_scheduler_e2e.py:19,24) — pytest_asyncio, ScheduledTask, and parse_interval are imported but never used.

from gaia.ui.database import ChatDatabase
from gaia.ui.routers.schedules import get_scheduler, router
from gaia.ui.scheduler import Scheduler

(drop the import pytest_asyncio line too).

🟢 Inaccurate type hint (scheduler.py:2137) — after: datetime = None should be Optional[datetime] = None to match the rest of the module's annotations.

🟢 HTTPException re-raises drop the cause (routers/schedules.py — the except ValueError as e: raise HTTPException(...) blocks). CLAUDE.md prefers raise … from e so the original traceback is preserved. Boundary translation is allowed, but from e costs nothing here.

🟢 Timing-based tests (test_scheduler.py, test_scheduler_e2e.py) lean on real asyncio.sleep(1.5–3.5s) with >= count asserts. Correct, but can flake on a loaded CI runner and adds ~30 s+ of wall time. Not blocking — flagging in case intermittent failures show up later.

Strengths

  • Fail-loudly done rightScheduleConfig.from_json raises on malformed/unknown-key JSON instead of returning a silently-empty config, _load_tasks propagates schema errors rather than booting with zero tasks, and the executor/concurrency guards reject bad construction. This is exactly the CLAUDE.md "no silent fallbacks" posture, and it's tested directly (TestScheduleConfigFromJson).
  • Persistence + restart coverage is genuinely thorough — paused/cancelled status, results, and active timers all verified to survive a scheduler restart against a real :memory: DB.
  • Clean layering — DB access goes through the public ChatDatabase API (parameterized SQL throughout, ON DELETE CASCADE + explicit result cleanup), the router uses a proper Depends(get_scheduler) with a 503 when the lifespan didn't start it, and docs were updated in the same PR.

Verdict

Approve with suggestions. No blocking correctness or security issues; the feature works and is well-covered. Please address the tunnel-suspend behavior (or at least reconcile the docs with what actually happens) — the rest are quick cleanups.

@github-actions

Copy link
Copy Markdown
Contributor

🟡 tests/unit/test_scheduler_api.py:3971 — dual event-loop deadlock risk

app_with_scheduler starts the scheduler on a manually-created loop, but TestClient uses its own internal anyio-backed event loop. When any test creates a schedule via client.post(...), create_task calls asyncio.create_task(self._run_loop(task)) inside the TestClient's loop — so the timer task lives there. Teardown then calls loop.run_until_complete(scheduler.shutdown()), which tries to await task._timer_task on the wrong loop. The task's done-callback is dispatched on the TestClient's loop, not loop, so loop may never be woken up — potential hang on teardown.

The other test files in this PR (test_scheduler.py, test_scheduler_e2e.py) use @pytest_asyncio.fixture correctly and avoid this entirely. test_scheduler_api.py should do the same:

@pytest_asyncio.fixture
async def app_with_scheduler():
    db = ChatDatabase(":memory:")
    scheduler = Scheduler(db=db, executor=fake_executor)
    await scheduler.start()
    app = FastAPI()
    app.include_router(router)
    app.state.scheduler = scheduler
    app.dependency_overrides[get_scheduler] = lambda: scheduler
    yield app, scheduler, db
    await scheduler.shutdown()
    db.close()

And replace TestClient with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") (same pattern as test_scheduler_e2e.py).

Drop unused dataclasses.field import in scheduler.py and unused
pytest_asyncio/ScheduledTask/parse_interval imports in the scheduler
e2e test. Resolves the Pylint W0611 / Flake8 F401 failures.
@github-actions

Copy link
Copy Markdown
Contributor

🟡 tests/unit/test_scheduler_api.py:70 — event-loop mismatch will cause teardown ValueError

The app_with_scheduler fixture starts the scheduler on a manually-created loop, but TestClient processes each request on its own anyio event loop. When a POST /api/schedules request fires, scheduler.create_task() calls asyncio.create_task(self._run_loop(task)) on TestClient's loop — not loop.

Teardown then calls loop.run_until_complete(scheduler.shutdown()). Inside shutdown():

task._timer_task.cancel()
try:
    await task._timer_task   # ← Task lives on a different loop → ValueError
except asyncio.CancelledError:
    pass

Only CancelledError is swallowed; ValueError: Task attached to a different loop propagates, causing pytest teardown errors for every test that creates a schedule.

Fix: use a pytest-asyncio async fixture so everything shares one loop:

@pytest_asyncio.fixture
async def app_with_scheduler():
    db = ChatDatabase(":memory:")
    scheduler = Scheduler(db=db, executor=fake_executor)
    await scheduler.start()
    app = FastAPI()
    app.include_router(router)
    app.dependency_overrides[get_scheduler] = lambda: scheduler
    yield app, scheduler, db
    await scheduler.shutdown()
    db.close()

The tests would then use httpx.AsyncClient + @pytest.mark.asyncio (same pattern as test_scheduler_e2e.py).

@github-actions

Copy link
Copy Markdown
Contributor

🟡 scheduler.py — zero-interval creates a spin-loop against the LLM

parse_interval("every 0s") returns 0, and create_task accepts it without validation. The run loop then does await asyncio.sleep(0) → yield → fire → repeat indefinitely at full speed. The UI blocks it (parsed.valid is false when interval_seconds == 0), but a direct API call can trigger it.

src/gaia/ui/scheduler.pycreate_task, just before self._tasks[name] = task:

        if interval_seconds <= 0:
            raise ValueError(
                f"Interval must be at least 1 second; got {interval_seconds}. "
                "Check your schedule string (e.g. 'every 30m', 'daily at 9am')."
            )

@kovtcharov-amd kovtcharov-amd enabled auto-merge June 11, 2026 00:28
@kovtcharov-amd kovtcharov-amd disabled auto-merge June 11, 2026 00:28
@itomek itomek added this pull request to the merge queue Jun 11, 2026
Merged via the queue into main with commit 110d2d1 Jun 11, 2026
26 checks passed
@itomek itomek deleted the kalin/scheduled-tasks branch June 11, 2026 13:37
TravisHaa added a commit to TravisHaa/gaia that referenced this pull request Jun 21, 2026
Prepare the schedule store to converge with the Agent UI's SQLite store
(amd#1566) and close the test/doc gaps the initial CLI PR deferred.

- Schedule gains last_run/next_run/session_ref so one dataclass serves
  both the hand-editable TOML layer and the future SQLite/run-history
  store; TOML serialization omits each field when unset.
- ScheduleStore becomes a runtime_checkable Protocol
  (load/save/add/remove/get/set_enabled/mark_run) and the TOML
  implementation is renamed TomlScheduleStore, so a database-backed
  store can drop in without changing the daemon or CLI.
- mark_run records last_run/next_run after a successful fire (daemon and
  `gaia schedule run`); a failed fire still logs loudly and leaves
  run-state untouched, keeping the daemon alive.
- build_scheduler now depends only on the Protocol surface (no concrete
  store.path access) so the UI server can embed the same engine on a
  background thread instead of requiring a separate daemon process.
- Add hermetic unit tests for the store, sinks, and daemon (LLM and
  network mocked), and document `gaia schedule` in docs/reference/cli.mdx.
pull Bot pushed a commit to bhardwajRahul/gaia that referenced this pull request Jul 6, 2026
## Why this matters

The v2 sidecar-first architecture (amd#1913) is signed-in design, but
nothing yet tells an implementer where to start, what's reusable vs.
net-new, or which unsigned decisions block which work — so the build
can't be parallelized or even safely begun. This adds the implementation
decomposition: a code-verified delta table for every v2 building block,
19 sequenced issue proposals (with acceptance criteria, dependencies,
S/M/L sizes — proposals only, none filed), a specced first vertical
slice (headless daemon + supervised email sidecar + `/query` streamed to
the CLI), and the three sign-off decisions (§0.4 confirmation model,
§0.9 custody home, §0.24 containment 🔒) as explicit questions.

It also pins verified design-vs-merged deviations so implementers don't
trip on them: `gaia api` still mounts email in-process (the pattern
amd#1910 removed from the UI only), the only scheduler on main is
UI-internal (PR amd#1566 — `gaia schedule`/PR amd#1371 is unmerged), and
contract 2.1 has no `/query`, no auth (amd#1706), and no `/shutdown`.

## Test plan

- [ ] Docs-only — no code paths touched
- [ ] Spot-check the delta table's file/LoC claims against `main` @
`080e7259` (all were verified while writing)
- [ ] Confirm the issue proposals don't duplicate existing open issues
(checked: amd#1706, amd#1653, amd#1896, amd#1717 are linked, not duplicated)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Documentation changes electron Electron app changes tests Test changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants