Skip to content

feat(email): scheduled send + snooze (one-shot scheduler, cancel handles) - #1919

Merged
kovtcharov merged 6 commits into
mainfrom
claudia/task-2a5db755
Jul 6, 2026
Merged

feat(email): scheduled send + snooze (one-shot scheduler, cancel handles)#1919
kovtcharov merged 6 commits into
mainfrom
claudia/task-2a5db755

Conversation

@kovtcharov

Copy link
Copy Markdown
Collaborator

"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 schedule cron 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 drive fire_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_send is in TOOLS_REQUIRING_CONFIRMATION; the never-auto-send invariant is unchanged.
  • python util/lint.py --all — clean.
  • Manual (needs a connected mailbox): gaia email, ask "send X to Y tomorrow at 9am" → confirmation shows literal payload + time; list_scheduled_jobs shows the job; cancel_scheduled_job cancels it; draft is visible in the mail client.

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.
@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

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 pending → firing claim that guarantees exactly-once firing, the snooze archive-rollback when the job write fails, and a dedicated per-pass DB connection so the polling thread never shares the agent's sqlite handle. Test coverage is genuinely thorough (16 tests hitting each acceptance criterion plus a cross-thread regression guard), and all four npm doc surfaces (README/SPEC/SKILL/CHANGELOG) plus the guide were updated together.

Only a few minor notes below, none blocking.

🔍 Technical details

🟢 Minor

Polling thread has no explicit stop hook (agent.py:349-358) — start_scheduler=True spins up a daemon thread but there's no stop()/close on the agent, so it's only reclaimed at process exit. That's correct for the long-lived REST sidecar (one agent per process), and tests disable it. It would only leak a thread if an EmailTriageAgent were constructed-and-discarded inside a long-lived process. Worth a small stop()/__del__ hook if that ever becomes a usage pattern — fine to leave as-is for now.

Concurrent-writer busy timeout is implicit (scheduler.py) — the polling thread's per-pass connection and the agent's main-thread connection both write email_scheduled_jobs on the same file. The atomic claim is correct (SQLite serializes writers), but collisions rely on sqlite3's default 5s busy timeout — there's no explicit busy_timeout/WAL. Very low risk at a 30s poll with tiny writes; noting it in case poll intervals ever shrink.

mark_failed sets fired_at (schedule_store.py:452-458) — a job that failed to fire gets a fired_at timestamp. Cosmetic only (the error column and failed status carry the real signal), but the column name reads as "this fired," which it didn't.

Strengths

  • Fail-loud throughout, matching the repo rule: missing executor → job marked failed with a persisted reason; executor exception → failed + log.exception; snooze rollback raises a message that names the exact mailbox state ("archived with no scheduled return") instead of leaving the message silently vanished.
  • The cross-thread sqlite trap is both avoided and tested_execute_scheduled_send writes its audit row through the scheduler's per-pass connection, and test_polling_thread_fires_scheduled_send_with_db_write is a real regression guard for it rather than a happy-path smoke test.
  • Correct scope discipline: schedule_send is added to TOOLS_REQUIRING_CONFIRMATION (Tier-2 at creation, feat(email): send with confirmation (never auto-send) #1264) while snooze stays ungated as a reversible organize-tier action, and the "no REST route yet" boundary is documented consistently across README/SPEC/SKILL so integrators don't look for a client.scheduleSend() that doesn't exist.

@kovtcharov
kovtcharov enabled auto-merge July 1, 2026 22:42
itomek
itomek previously approved these changes Jul 6, 2026
# Conflicts:
#	hub/agents/npm/agent-email/CHANGELOG.md
#	hub/agents/python/email/gaia_agent_email/agent.py
@itomek

itomek commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

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 EmailJobScheduler polling thread (no injected clocks):

Step Result
schedule_send ~75s out (planted subject/body) Job persisted pending; body kept out of the SQLite payload (privacy invariant held)
Real fire fired_at − due_at = +1.027s — at/after due, never early; draft sent once
One-shot 3 further poll passes: send count still 1, row status=fired (consumed)
Cancel handle Second job flipped to cancelled; double-cancel rejected loudly (no silent no-op)
Snooze 30s Message left INBOX, resurfaced at +1.040s after due with prior labels restored
LLM-in-the-loop Live Gemma chose schedule_send with correct args; the new confirmation gate fired (15.1s end-to-end)
Notes
  • Only red check ("API Tests") is pre-existing on main — identical email-route 404 signature on main's latest run (50c17de). behavior-e2e is green after the update.
  • Merge to main: 2 conflicts (CHANGELOG + system-prompt block vs the merged voice tools), both sides kept. src/gaia/agents/base/agent.py change is a single addition of schedule_send to TOOLS_REQUIRING_CONFIRMATION — no prompt-assembly or parsing paths touched.
  • Local suite: 16/16 new schedule tests; 1311 passed across email suites; full tests/unit/ failures (8) all reproduce identically on main (pre-existing). Lint fully green.
  • Minor observation, non-blocking: the scheduler logs "Database initialized" at INFO on every poll pass (~2,880 lines/day at the 30s default) — worth downgrading to DEBUG or logging once in a follow-up.

itomek
itomek previously approved these changes Jul 6, 2026
# Conflicts:
#	docs/guides/email.mdx
#	hub/agents/npm/agent-email/CHANGELOG.md
#	hub/agents/npm/agent-email/README.md
itomek and others added 3 commits July 6, 2026 15:52
# 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.
@itomek

itomek commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

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.

@kovtcharov
kovtcharov added this pull request to the merge queue Jul 6, 2026
Merged via the queue into main with commit 491eb1f Jul 6, 2026
51 checks passed
@kovtcharov
kovtcharov deleted the claudia/task-2a5db755 branch July 6, 2026 21:33
pull Bot pushed a commit to bhardwajRahul/gaia that referenced this pull request Jul 7, 2026
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>
pull Bot pushed a commit to bhardwajRahul/gaia that referenced this pull request Jul 14, 2026
… 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>
kovtcharov pushed a commit that referenced this pull request Jul 18, 2026
…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
kovtcharov pushed a commit that referenced this pull request Jul 18, 2026
…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
kovtcharov pushed a commit that referenced this pull request Jul 18, 2026
…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
kovtcharov pushed a commit that referenced this pull request Jul 18, 2026
…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
pull Bot pushed a commit to bhardwajRahul/gaia that referenced this pull request Jul 18, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agent::email Email agent changes agents documentation Documentation changes tests Test changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(email): scheduled send + snooze

3 participants