Skip to content

Commit e0886cb

Browse files
kovtcharov-amdOvtcharov
andauthored
refactor(daemon): reconcile the clocks into one daemon-owned scheduler (V2-15) (#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` #1918, `EmailJobScheduler` #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 (#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 **#2153** (custody store), which is being built in parallel. The clock's SQLite store is deliberately custody-agnostic so it swaps onto #2153's host-custody store without touching this logic. Expect a rebase once #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 #2014 Closes #2156 Co-authored-by: Ovtcharov <kovtchar@amd.com>
1 parent 1545915 commit e0886cb

18 files changed

Lines changed: 1504 additions & 2 deletions

File tree

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# Changelog — `gaia-agent-email`
2+
3+
All notable changes to the GAIA Email Triage agent package are recorded here.
4+
Format loosely follows [Keep a Changelog](https://keepachangelog.com/); the REST
5+
contract version is tracked separately as
6+
`gaia_agent_email.contract.SCHEMA_VERSION` (see `CONTRACT.md`).
7+
8+
## [Unreleased]
9+
10+
### Changed
11+
12+
- **Daemon-supervised scheduling (V2-15, #2156).** When the GAIA daemon spawns
13+
the sidecar it sets `GAIA_DAEMON_SUPERVISED=1`; in that mode the sidecar's two
14+
embedded clocks — the daily `BriefingScheduler` (#1918) and the one-shot
15+
`EmailJobScheduler` polling thread (#1919) — no longer start. The daemon owns
16+
a single reconciled clock and drives those jobs itself, so a scheduled brief
17+
or send now fires even with the web UI and CLI closed, and can no longer be
18+
silently killed when an idle sidecar is reaped.
19+
20+
This is **additive and gated by supervision context, not a deletion**: a
21+
standalone `gaia-agent-email serve`, a bare integrator, or a
22+
`CustodyProvider` deployment never sees the env var and keeps both embedded
23+
clocks live exactly as before. The frozen `/v1/email/*` REST contract and
24+
`SCHEMA_VERSION` are unchanged.
25+
26+
### Added
27+
28+
- `gaia_agent_email.supervision.is_daemon_supervised()` — detects the daemon
29+
supervision handshake (the env-var name is owned by core in
30+
`gaia.daemon.constants`, so daemon and sidecar can never drift).
31+
- `gaia_agent_email.daemon_migration` — adapter that lifts the embedded clocks'
32+
jobs (pending `schedule_store` one-shots + the enabled daily briefing) into
33+
the daemon clock **exactly once** via the core reconciler's migration ledger,
34+
and asserts no job is silently dropped in the process.

hub/agents/python/email/gaia_agent_email/agent.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ class never passes ``use_claude=True`` / ``use_chatgpt=True`` to
5252
AGENT_NAMESPACED_ID,
5353
ALL_SCOPES,
5454
)
55+
from gaia_agent_email.supervision import is_daemon_supervised
5556
from gaia_agent_email.tools.calendar_tools import CalendarToolsMixin
5657
from gaia_agent_email.tools.delete_tools import DeleteToolsMixin
5758
from gaia_agent_email.tools.followup_tools import FollowupToolsMixin
@@ -504,8 +505,18 @@ def __init__(self, config: Optional[EmailAgentConfig] = None):
504505
},
505506
poll_seconds=config.scheduler_poll_seconds,
506507
)
507-
if config.start_scheduler:
508+
# V2-15 (#2156): under daemon supervision the daemon drives one-shot
509+
# jobs from its single reconciled clock, so the embedded polling thread
510+
# stays off — two drivers over one store risks a double-fire. Standalone
511+
# / bare integrator runs (no supervision env) keep the thread live.
512+
if config.start_scheduler and not is_daemon_supervised():
508513
self._scheduler.start()
514+
elif config.start_scheduler:
515+
logger.info(
516+
"Email agent under daemon supervision: embedded "
517+
"EmailJobScheduler polling thread gated off (the daemon drives "
518+
"scheduled send / snooze from its reconciled clock)."
519+
)
509520

510521
# -- Agent contract -----------------------------------------------------
511522

hub/agents/python/email/gaia_agent_email/briefing.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,13 @@ class BriefingScheduler:
291291
A failed run (mailbox disconnected, provider outage) is logged with its
292292
actionable message and the schedule continues; it is never retried
293293
silently or downgraded to a partial briefing.
294+
295+
Daemon supervision (V2-15, #2156): when the GAIA daemon spawns this sidecar
296+
it drives the daily brief from its single reconciled clock, so
297+
``server.py`` does NOT start this in-process timer (see
298+
:func:`gaia_agent_email.supervision.is_daemon_supervised`). Standalone /
299+
bare-integrator runs are unaffected and keep this timer live — the seam the
300+
#1371 dispatcher note below anticipated is now realized by the daemon.
294301
"""
295302

296303
def __init__(
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved.
2+
# SPDX-License-Identifier: MIT
3+
"""Adapter: lift the email sidecar's in-process clock jobs into the daemon
4+
clock (V2-15, #2156).
5+
6+
This is the email-specific half of the reconciliation. It reads the two
7+
embedded clocks' durable state and maps it to the daemon's hub-safe
8+
:class:`~gaia.daemon.scheduler.models.MigratableJob` vocabulary, then hands the
9+
batch to the core reconciler. Living in the hub package keeps the dependency
10+
direction legal: hub -> core is fine, and core never learns anything
11+
email-specific.
12+
13+
Two sources fold in here:
14+
15+
- ``EmailJobScheduler`` / ``schedule_store`` (#1919) — persistent one-shot jobs
16+
(scheduled send, snooze) already keyed by a stable ``job_id``. Each pending
17+
job becomes a one-shot :class:`MigratableJob`.
18+
- ``BriefingScheduler`` (#1918) — a recurring daily brief configured from env,
19+
with no per-job row. When enabled it contributes a single recurring
20+
:class:`MigratableJob` with a synthetic-but-stable ``source_job_id`` so a
21+
re-run migrates it exactly once.
22+
23+
The reconcile is idempotent: run it every time the daemon (re)adopts the email
24+
sidecar; the ledger makes the second and later passes no-ops.
25+
"""
26+
27+
from __future__ import annotations
28+
29+
from datetime import datetime
30+
from typing import List, Optional
31+
32+
from gaia_agent_email import schedule_store
33+
from gaia_agent_email.briefing import (
34+
BriefingScheduleConfig,
35+
seconds_until_next_run,
36+
)
37+
38+
from gaia.daemon.scheduler import (
39+
KIND_ONE_SHOT,
40+
KIND_RECURRING,
41+
MigratableJob,
42+
MigrationResult,
43+
assert_no_dropped,
44+
reconcile_jobs,
45+
)
46+
47+
# Source names recorded in the daemon migration ledger. Stable strings — they
48+
# key the exactly-once guard, so they must never change once shipped.
49+
SOURCE_ONE_SHOT = "email:schedule_store"
50+
SOURCE_BRIEFING = "email:briefing"
51+
52+
# The briefing has no per-job row; this fixed id gives it a stable ledger key so
53+
# re-adoption is idempotent. One daily brief per sidecar identity.
54+
BRIEFING_JOB_ID = "daily_inbox_briefing"
55+
56+
57+
def collect_one_shot_jobs(db) -> List[MigratableJob]:
58+
"""Every still-pending one-shot email job as a :class:`MigratableJob`."""
59+
schedule_store.init_schema(db)
60+
jobs: List[MigratableJob] = []
61+
for row in schedule_store.list_jobs(db, status=schedule_store.STATUS_PENDING):
62+
jobs.append(
63+
MigratableJob(
64+
source=SOURCE_ONE_SHOT,
65+
source_job_id=row["job_id"],
66+
kind=KIND_ONE_SHOT,
67+
fire_at=row["due_at"],
68+
payload={
69+
"kind": row["kind"],
70+
"mailbox": row["mailbox"],
71+
"payload": row["payload"],
72+
},
73+
)
74+
)
75+
return jobs
76+
77+
78+
def collect_briefing_job(
79+
config: BriefingScheduleConfig, *, now: Optional[datetime] = None
80+
) -> List[MigratableJob]:
81+
"""The daily briefing as a recurring :class:`MigratableJob`, or [] when off.
82+
83+
A disabled briefing contributes nothing — matching the embedded scheduler,
84+
which creates no task when disabled. The first ``fire_at`` is the next local
85+
occurrence of the configured time; the interval is a fixed 24h.
86+
"""
87+
config.validate()
88+
if not config.enabled:
89+
return []
90+
reference = now or datetime.now()
91+
delay = seconds_until_next_run(config.time_of_day, reference)
92+
return [
93+
MigratableJob(
94+
source=SOURCE_BRIEFING,
95+
source_job_id=BRIEFING_JOB_ID,
96+
kind=KIND_RECURRING,
97+
interval_seconds=86400,
98+
fire_at=reference.timestamp() + delay,
99+
payload={
100+
"time_of_day": config.time_of_day,
101+
"max_messages": config.max_messages,
102+
},
103+
)
104+
]
105+
106+
107+
def migrate_email_clocks(
108+
db,
109+
*,
110+
briefing_config: Optional[BriefingScheduleConfig] = None,
111+
now: Optional[datetime] = None,
112+
verify_no_dropped: bool = True,
113+
) -> MigrationResult:
114+
"""Adopt both embedded email clocks into the daemon clock, exactly once.
115+
116+
``db`` is a ``DatabaseMixin`` handle open on the daemon clock's store (the
117+
same file the daemon drives). Pass ``briefing_config`` to fold the daily
118+
brief in; omit it to migrate only the one-shot jobs.
119+
120+
When ``verify_no_dropped`` is set (the default) the one-shot batch is
121+
checked with :func:`assert_no_dropped` after the pass — every pending email
122+
job must have reached the ledger, or a :class:`DroppedJobError` is raised so
123+
the migration fails loudly instead of silently losing a scheduled send.
124+
"""
125+
one_shot = collect_one_shot_jobs(db)
126+
briefing = (
127+
collect_briefing_job(briefing_config, now=now)
128+
if briefing_config is not None
129+
else []
130+
)
131+
result = reconcile_jobs(db, one_shot + briefing)
132+
133+
if verify_no_dropped:
134+
assert_no_dropped(
135+
db,
136+
source=SOURCE_ONE_SHOT,
137+
source_job_ids=[j.source_job_id for j in one_shot],
138+
)
139+
if briefing:
140+
assert_no_dropped(
141+
db,
142+
source=SOURCE_BRIEFING,
143+
source_job_ids=[BRIEFING_JOB_ID],
144+
)
145+
return result
146+
147+
148+
__all__: List[str] = [
149+
"BRIEFING_JOB_ID",
150+
"SOURCE_BRIEFING",
151+
"SOURCE_ONE_SHOT",
152+
"collect_briefing_job",
153+
"collect_one_shot_jobs",
154+
"migrate_email_clocks",
155+
]

hub/agents/python/email/gaia_agent_email/scheduler.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,13 @@
2424
``start()``-ing the thread. The store and executors don't change — only the
2525
driver does. Kept email-scoped and minimal on purpose.
2626
27+
Daemon supervision (V2-15, #2156): under the GAIA daemon this thread is NOT
28+
started (see :func:`gaia_agent_email.supervision.is_daemon_supervised`); the
29+
daemon drives these jobs from its single reconciled clock after adopting them
30+
via :func:`gaia_agent_email.daemon_migration.migrate_email_clocks`. Standalone
31+
runs keep the thread — the seam above, realized. The atomic ``claim_job`` guard
32+
means even if both drivers briefly poll the same store, each job fires once.
33+
2734
Fail-loudly contract: an executor failure marks the job ``failed`` with the
2835
error message persisted on the row and logs at ERROR — a firing send must
2936
never silently swallow a send failure. A job whose kind has no registered

hub/agents/python/email/gaia_agent_email/server.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ def build_app():
8181
from gaia_agent_email.briefing import BriefingScheduleConfig, BriefingScheduler
8282
from gaia_agent_email.connector_routes import router as connector_router
8383
from gaia_agent_email.contract import SCHEMA_VERSION
84+
from gaia_agent_email.supervision import is_daemon_supervised
8485

8586
# Daily inbox briefing (#1608) — env config is read at build time so an
8687
# invalid value aborts startup loudly, not at the first scheduled fire.
@@ -89,6 +90,18 @@ def build_app():
8990

9091
@asynccontextmanager
9192
async def lifespan(_app):
93+
# V2-15 (#2156): under daemon supervision the daemon drives the brief
94+
# from its single reconciled clock, so the embedded clock stays dark —
95+
# running both over one store is a double-run. Standalone / bare
96+
# integrator runs (no supervision env) keep the embedded clock live.
97+
if is_daemon_supervised():
98+
log.info(
99+
"Email sidecar under daemon supervision: embedded "
100+
"BriefingScheduler gated off (the daemon drives the daily "
101+
"brief from its reconciled clock)."
102+
)
103+
yield
104+
return
92105
scheduler = BriefingScheduler(briefing_config)
93106
scheduler.start()
94107
try:
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved.
2+
# SPDX-License-Identifier: MIT
3+
"""Daemon-supervision detection for the email sidecar (V2-15, #2156).
4+
5+
When the GAIA daemon spawns this sidecar it sets
6+
``GAIA_DAEMON_SUPERVISED=1`` in the environment. In that mode the daemon owns
7+
the clock: it drives the briefing and one-shot jobs from its single reconciled
8+
scheduler, so the sidecar's OWN embedded schedulers (``BriefingScheduler`` #1918,
9+
``EmailJobScheduler`` #1919) must NOT also run — two clocks over one store is the
10+
double-run this reconciliation exists to prevent.
11+
12+
A sidecar started any other way — a bare integrator, a standalone
13+
``gaia-agent-email serve``, an embedded ``CustodyProvider`` deployment — never
14+
sees the var and keeps its embedded clocks live. The check is a supervision
15+
*context* test, deliberately NOT a deletion, so standalone scheduling behavior
16+
(and its test suite) is untouched.
17+
18+
The env-var NAME is owned by core (``gaia.daemon.constants``) so the daemon that
19+
sets it and the sidecar that reads it can never drift. Importing a core constant
20+
from a hub package is allowed; core never imports a hub wheel.
21+
"""
22+
23+
from __future__ import annotations
24+
25+
import os
26+
from typing import Mapping, Optional
27+
28+
from gaia.daemon.constants import (
29+
DAEMON_SUPERVISION_ENABLED_VALUE,
30+
DAEMON_SUPERVISION_ENV_VAR,
31+
)
32+
33+
34+
def is_daemon_supervised(environ: Optional[Mapping[str, str]] = None) -> bool:
35+
"""True when the daemon is driving this sidecar's clock.
36+
37+
Only the exact enabled value counts — any other value (including a stray
38+
empty string) means "not supervised", so a misconfigured env never
39+
silently disables the embedded clocks a standalone run depends on.
40+
"""
41+
env = os.environ if environ is None else environ
42+
return env.get(DAEMON_SUPERVISION_ENV_VAR) == DAEMON_SUPERVISION_ENABLED_VALUE
43+
44+
45+
__all__ = ["is_daemon_supervised"]

0 commit comments

Comments
 (0)