feat(email): scheduled send + snooze (one-shot scheduler, cancel handles) - #1919
Conversation
Users can now say "send this tomorrow at 9am" or "snooze this until Monday" — the two standard mail-client conveniences the agent lacked (capability 17 in #1691, gap G6). Neither Gmail nor Outlook exposes these through their APIs, so both are implemented GAIA-side on a new one-shot fire-at-T scheduler. - schedule_send: Tier-2 confirmation AT CREATION (#1264) — the user approves the literal recipient/subject/body and the fire time; the send then fires unattended at/after that time. The body is stored as a regular mailbox draft (visible in the user's mail client), never in SQLite; firing reuses send_draft_impl so the audit trail matches an interactive send. - snooze_message: archive now via the existing organize path, re-add INBOX at the chosen time via unarchive_message with the prior label set (provider-correct for Gmail and Outlook). If persisting the re-surface job fails, the archive is rolled back — a message never silently vanishes from INBOX. - Both cancellable before they fire (cancel_scheduled_job / list_scheduled_jobs); cancelling a non-pending job is a loud error. The scheduler (EmailJobScheduler) polls a persistent SQLite job store from a daemon thread and claims each job with an atomic pending->firing UPDATE, so a job fires exactly once even with two drivers; past-due jobs from a previous run fire on the next start. It opens its own connection per pass — sharing the agent's sqlite3 handle across threads is a use-after-close crash. A firing failure is persisted on the job row and logged at ERROR, never swallowed. PR #1371 (gaia schedule cron dispatch) is approved but unmerged, so the store is email-scoped with a documented seam: once #1371 lands (autonomy epic #555), its dispatcher can call fire_due_jobs() on its cadence instead of the polling thread — no store or tool changes.
|
Verdict: Approve — no blocking issues. This adds scheduled-send and snooze to the email agent via a new persistent one-shot job scheduler: a scheduled send is confirmed at creation (literal recipient/subject/body + fire time), the body stays as a mailbox draft rather than in SQLite, jobs survive restarts and fire on the next start if their time already passed, and firing failures are recorded on the job and logged rather than swallowed. The design is careful in the places that matter — the "backend call first, job row only on success" ordering, the atomic Only a few minor notes below, none blocking. 🔍 Technical details🟢 MinorPolling thread has no explicit stop hook ( Concurrent-writer busy timeout is implicit (
Strengths
|
# Conflicts: # hub/agents/npm/agent-email/CHANGELOG.md # hub/agents/python/email/gaia_agent_email/agent.py
|
Branch updated to main and the scheduler verified working on real hardware with real wall-clock timing — this PR is ready to merge. Real-world proof — Ryzen AI box (Radeon, Ubuntu), Lemonade v10.8.1 + Gemma-4-E4B, real
Notes
|
# Conflicts: # docs/guides/email.mdx # hub/agents/npm/agent-email/CHANGELOG.md # hub/agents/npm/agent-email/README.md
# Conflicts: # hub/agents/npm/agent-email/CHANGELOG.md
Cap 17 shipped with #1609 but the spec table/cards still tagged it Planned after the main merge. Flip it to Wired and correct the wired/planned counts and cross-references (cap 14 task extraction is also Wired, not Planned) that assumed the stale state.
|
Final status: fully green and mergeable at dc05e4f — conflicts from the #1917/#1921 merges resolved, all checks passing (the API Tests fix from #1939 is now on main), 822 tests + lint green locally. Real-world scheduler evidence is in the comment above; the spec's capability-17 row was also corrected to Wired in this update. |
The "API Tests" check has been red on every branch including main since
the email agent moved to a standalone hub wheel: the workflow installs
the routing and code hub packages into its venv but not
`gaia-agent-email`, so the email REST router never mounts and every
`/v1/email` test in `tests/test_api.py` fails with `404 {"detail":"Not
Found"}`. One install line fixes it.
**Test plan**
- [ ] "API Tests" check on this PR goes green (the email tests were the
only failures — same signature as main run 28462826567)
- [ ] After merge, re-run API Tests on open PRs amd#1917/amd#1919 — they
should go fully green
<details>
<summary>Evidence</summary>
- Main's latest run of this workflow (28462826567 @ 50c17de) fails with
the identical email-route 404 list as every PR branch — e.g.
`TestEmailTriageEndpoint::test_single_email_in_structured_out`: `assert
404 == 200`.
- Last green main run of this workflow was amd#1455 (2026-06-26) — the last
commit to touch `test_api.yml`; the failures start with the
hub-migration commits that followed.
- The workflow already installs the other two hub wheels for exactly
this reason (routing/code, amd#1102) — email was missed.
</details>
Co-authored-by: Tomasz Iniewicz <tomasz@iniewicz.com>
… refresh (amd#2058) Closes amd#2017. Part of the Agent UI v2 epic (amd#2014), Phase 0. Four sibling plan docs describe the pre-v2 (localhost-trust, UI-owned supervisor, sidecar-reads-grants) model as if it were current — a reader following the wrong one builds the wrong thing. Separately, a week of merged email work made several of the v2 breakdown doc's "verified" facts stale (wrong contract version, closed issues, new in-sidecar clocks). This aligns the docs with head of main so implementers of the later batches aren't misled. Adds the⚠️ superseded/reconcile banner to `security-model.mdx`, `email-sidecar-agent-ui-implementation.md`, `connectors.mdx`, and `autonomy-engine.mdx`; fixes the `agent-ui.mdx` sentence that wrongly put RAG/memory in sidecars; and refreshes `agent-ui-v2-implementation-breakdown.md` (contract is 2.3 not 2.1, amd#1706 closed by amd#1980, two merged in-sidecar clocks amd#1918/amd#1919 + durable stores widen V2-15, hub paths updated to agent-first). **Test plan** - [ ] All four banners render and point at v2 §0. - [ ] `agent-ui.mdx` no longer claims RAG/memory live in sidecars. - [ ] Breakdown-doc facts match head of main. --------- Co-authored-by: Ovtcharov <kovtchar@amd.com>
…r (V2-15) Before: four independent clocks — the UI backend Scheduler, the `gaia schedule` CLI, and the email sidecar's two in-process clocks (BriefingScheduler #1918, EmailJobScheduler #1919) — each died with the process that owned it, so an "always-on" brief or scheduled send stopped firing the moment the UI/CLI/sidecar closed, and V2-6's idle reaper had to stay disabled (#2142) because reaping a sidecar silently killed its clock. After: a single daemon-owned clock (`gaia.daemon.scheduler`) with an exactly-once migration ledger. The email sidecar's jobs migrate into it exactly once — a re-run is a no-op, an unschedulable job raises loudly, and a dropped job is detected loudly (assert_no_dropped) rather than silently lost. Under daemon supervision (GAIA_DAEMON_SUPERVISED, injected at spawn) the sidecar gates its own embedded clocks off; standalone / bare-integrator runs keep them live, so scheduling never silently stops for anyone the daemon isn't driving. Scope note (draft): the daemon clock + reconciliation + email gating land here; rewiring routers/schedules.py, routers/goals.py and the `gaia schedule` CLI to thin daemon clients rides on the #2153 custody store and is a follow-up. The clock's SQLite store is custody-agnostic so it swaps onto the #2153 host-custody store without touching this logic. Part of #2014 Closes #2156
…r (V2-15) Before: four independent clocks — the UI backend Scheduler, the `gaia schedule` CLI, and the email sidecar's two in-process clocks (BriefingScheduler #1918, EmailJobScheduler #1919) — each died with the process that owned it, so an "always-on" brief or scheduled send stopped firing the moment the UI/CLI/sidecar closed, and V2-6's idle reaper had to stay disabled (#2142) because reaping a sidecar silently killed its clock. After: a single daemon-owned clock (`gaia.daemon.scheduler`) with an exactly-once migration ledger. The email sidecar's jobs migrate into it exactly once — a re-run is a no-op, an unschedulable job raises loudly, and a dropped job is detected loudly (assert_no_dropped) rather than silently lost. Under daemon supervision (GAIA_DAEMON_SUPERVISED, injected at spawn) the sidecar gates its own embedded clocks off; standalone / bare-integrator runs keep them live, so scheduling never silently stops for anyone the daemon isn't driving. Scope note (draft): the daemon clock + reconciliation + email gating land here; rewiring routers/schedules.py, routers/goals.py and the `gaia schedule` CLI to thin daemon clients rides on the #2153 custody store and is a follow-up. The clock's SQLite store is custody-agnostic so it swaps onto the #2153 host-custody store without touching this logic. Part of #2014 Closes #2156
…r (V2-15) Before: four independent clocks — the UI backend Scheduler, the `gaia schedule` CLI, and the email sidecar's two in-process clocks (BriefingScheduler #1918, EmailJobScheduler #1919) — each died with the process that owned it, so an "always-on" brief or scheduled send stopped firing the moment the UI/CLI/sidecar closed, and V2-6's idle reaper had to stay disabled (#2142) because reaping a sidecar silently killed its clock. After: a single daemon-owned clock (`gaia.daemon.scheduler`) with an exactly-once migration ledger. The email sidecar's jobs migrate into it exactly once — a re-run is a no-op, an unschedulable job raises loudly, and a dropped job is detected loudly (assert_no_dropped) rather than silently lost. Under daemon supervision (GAIA_DAEMON_SUPERVISED, injected at spawn) the sidecar gates its own embedded clocks off; standalone / bare-integrator runs keep them live, so scheduling never silently stops for anyone the daemon isn't driving. Scope note (draft): the daemon clock + reconciliation + email gating land here; rewiring routers/schedules.py, routers/goals.py and the `gaia schedule` CLI to thin daemon clients rides on the #2153 custody store and is a follow-up. The clock's SQLite store is custody-agnostic so it swaps onto the #2153 host-custody store without touching this logic. Part of #2014 Closes #2156
…r (V2-15) Before: four independent clocks — the UI backend Scheduler, the `gaia schedule` CLI, and the email sidecar's two in-process clocks (BriefingScheduler #1918, EmailJobScheduler #1919) — each died with the process that owned it, so an "always-on" brief or scheduled send stopped firing the moment the UI/CLI/sidecar closed, and V2-6's idle reaper had to stay disabled (#2142) because reaping a sidecar silently killed its clock. After: a single daemon-owned clock (`gaia.daemon.scheduler`) with an exactly-once migration ledger. The email sidecar's jobs migrate into it exactly once — a re-run is a no-op, an unschedulable job raises loudly, and a dropped job is detected loudly (assert_no_dropped) rather than silently lost. Under daemon supervision (GAIA_DAEMON_SUPERVISED, injected at spawn) the sidecar gates its own embedded clocks off; standalone / bare-integrator runs keep them live, so scheduling never silently stops for anyone the daemon isn't driving. Scope note (draft): the daemon clock + reconciliation + email gating land here; rewiring routers/schedules.py, routers/goals.py and the `gaia schedule` CLI to thin daemon clients rides on the #2153 custody store and is a follow-up. The clock's SQLite store is custody-agnostic so it swaps onto the #2153 host-custody store without touching this logic. Part of #2014 Closes #2156
…r (V2-15) (amd#2199) ## Why this matters Four independent clocks used to run GAIA's periodic work — the UI backend's `Scheduler`, the `gaia schedule` CLI, and the email sidecar's two in-process clocks (`BriefingScheduler` amd#1918, `EmailJobScheduler` amd#1919) — and **each died with the process that owned it**. A briefing or scheduled send stopped firing the moment the UI/CLI/sidecar closed, so "always-on" was false; worse, V2-6's idle-sidecar reaper had to stay disabled (amd#2142) because reaping a sidecar silently killed its clock. This lands the spine of the fix: **one daemon-owned clock** (`gaia.daemon.scheduler`) that adopts the in-sidecar jobs **exactly once** through a migration ledger. A re-run of the migration is a no-op, an unschedulable job raises loudly, and a dropped job is detected loudly (`assert_no_dropped`) instead of silently vanishing — the exact regression the epic flagged as most likely here. Under daemon supervision (`GAIA_DAEMON_SUPERVISED`, injected at spawn) the email sidecar gates its own embedded clocks off; standalone / bare-integrator / `CustodyProvider` runs never see the var and keep them live, so scheduling never silently stops for anyone the daemon isn't driving. **Draft, and why:** the daemon clock + reconciliation + email gating are complete and tested here. Rewiring `routers/schedules.py`, `routers/goals.py`, and the `gaia schedule` CLI into thin daemon clients — and landing schedule/task state in *host custody* (AC #2) — rides on **amd#2153** (custody store), which is being built in parallel. The clock's SQLite store is deliberately custody-agnostic so it swaps onto amd#2153's host-custody store without touching this logic. Expect a rebase once amd#2153 lands; the idle-reaper flip (AC #6) is gated behind that same follow-up so gating lands before reaping, never after. ## Test plan - [x] `python -m pytest tests/unit/test_daemon_scheduler.py tests/unit/test_daemon_scheduler_migration.py` — clock fires exactly-once, recurring re-arms, missing executor / executor exception fail loudly (not dropped), atomic claim blocks double-fire; reconcile is idempotent, dropped-job + unschedulable-job raise loudly, no double-run when old+new paths coexist. - [x] `python -m pytest hub/agents/python/email/tests/test_daemon_migration.py` — **exactly-once migration of the in-sidecar jobs** (the flagged regression): real `schedule_store` one-shots + the daily briefing migrate once, a re-run adopts nothing, a fired job is never re-migrated; supervision gate detected only on the exact env value. - [x] `python -m pytest hub/agents/python/email/tests/test_email_schedule.py` — existing email scheduler suite stays green (standalone parity, AC #4): 16 passed. - [x] `python util/lint.py` — black + isort + flake8(F) clean on all changed files (pre-existing unrelated failures noted below). Pre-existing, unrelated to this change (identical on `main`): `tests/unit/test_agent_sidecar_manager.py` and one `test_daemon_agents_routes.py` case fail on Windows (`os.killpg` / group-kill are Unix-only); `tests/unit/test_schedule_daemon.py` errors on a missing `tomli_w` dep in this env. Part of amd#2014 Closes amd#2156 Co-authored-by: Ovtcharov <kovtchar@amd.com>
"Send this tomorrow at 9am" and "snooze this until Monday" simply didn't exist in the email agent — both are table-stakes in every comparable mail client, and neither Gmail nor Outlook exposes them via API (they're client-side features), so users had no workaround. This adds both as agent tools on a new persistent one-shot scheduler: a scheduled send is user-confirmed at creation (literal recipient/subject/body + fire time, per the #1264 Tier-2 gate) and then fires unattended at/after its time; snooze drops the message out of INBOX now and brings it back at the chosen time. Both are cancellable until they fire, jobs survive restarts (past-due jobs fire on next start), and a firing failure is recorded on the job and logged — never silently swallowed.
Since PR #1371 (
gaia schedulecron dispatch) is approved but unmerged, the scheduler is deliberately minimal and email-scoped, with a documented seam: once #1371 lands (autonomy epic #555), its dispatcher can drivefire_due_jobs()directly — no store or tool changes. Privacy note: the pending send lives as a regular draft in the user's mailbox; the body is never persisted in the agent's SQLite.Closes #1609
Test plan
python -m pytest hub/agents/python/email/tests/test_email_schedule.py -v— 16 tests covering the issue's acceptance criteria: fires once at/after its time and not before; snoozed message leaves INBOX and re-surfaces on time; cancel prevents both; send failure at fire time is persisted + loud; snooze rolls back the archive if the job write fails; jobs persist across restart; a scheduled send fired from the real polling thread writes its audit row safely (cross-thread sqlite regression guard).python -m pytest tests/unit/agents/test_email_agent.py tests/unit/agents/test_email_agent_confirmation.py tests/integration/test_never_auto_send.py— confirmation gating:schedule_sendis inTOOLS_REQUIRING_CONFIRMATION; the never-auto-send invariant is unchanged.python util/lint.py --all— clean.gaia email, ask "send X to Y tomorrow at 9am" → confirmation shows literal payload + time;list_scheduled_jobsshows the job;cancel_scheduled_jobcancels it; draft is visible in the mail client.