Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license

This commit is contained in:
2026-09-05 13:26:46 +03:00
commit 03634b1ca3
11340 changed files with 3442369 additions and 0 deletions
+44
View File
@@ -0,0 +1,44 @@
"""
Cron job scheduling system for Hermes Agent.
This module provides scheduled task execution, allowing the agent to:
- Run automated tasks on schedules (cron expressions, intervals, one-shot)
- Self-schedule reminders and follow-up tasks
- Execute tasks in isolated sessions (no prior context)
Cron jobs are executed automatically by the gateway daemon:
hermes gateway install # Install as a user service
sudo hermes gateway install --system # Linux servers: boot-time system service
hermes gateway # Or run in foreground
The gateway ticks the scheduler every 60 seconds. A file lock prevents
duplicate execution if multiple processes overlap.
"""
from cron.jobs import (
create_job,
get_job,
list_jobs,
remove_job,
update_job,
pause_job,
resume_job,
trigger_job,
rearm_oneshot,
JOBS_FILE,
)
from cron.scheduler import tick
__all__ = [
"create_job",
"get_job",
"list_jobs",
"remove_job",
"update_job",
"pause_job",
"resume_job",
"trigger_job",
"rearm_oneshot",
"tick",
"JOBS_FILE",
]
+799
View File
@@ -0,0 +1,799 @@
"""Automation Blueprints — parameterized automation blueprints with typed slots.
A *blueprint* is a one-place definition of an automation that every surface
renders natively:
* Dashboard / GUI app -> a form (one field per slot)
* CLI / TUI / messenger -> a pre-filled ``/blueprint`` slash command
* Agent -> a seed prompt; it asks for any blank/ambiguous slot
* Docs catalog -> a copy-paste command + a ``hermes://`` deep-link
The single source of truth is the slot schema below. ``blueprint_form_schema``
emits what a form renderer needs; ``blueprint_slash_command`` emits the flattened
one-line command; ``fill_blueprint`` validates user-supplied values and turns a
blueprint into a ``cron.jobs.create_job`` kwargs dict (so there is no second job
engine). The form-where-there's-a-screen / agent-fills-where-there's-a-chat
split both consume this same module.
Design choice: users never type raw cron. A blueprint carries a fixed recurrence
in ``schedule_template`` and parameterizes only the human-friendly parts
(time-of-day, weekday set). Blueprints needing full flexibility expose a ``text``
slot named ``schedule`` that passes through verbatim.
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
__all__ = [
"BlueprintSlot",
"AutomationBlueprint",
"CATALOG",
"get_blueprint",
"blueprint_form_schema",
"blueprint_slash_command",
"blueprint_deeplink",
"blueprint_catalog_entry",
"fill_blueprint",
"BlueprintFillError",
"WEEKDAY_PRESETS",
]
class BlueprintFillError(ValueError):
"""Raised when supplied slot values fail validation."""
# Slot types the renderers understand.
_SLOT_TYPES = frozenset({"time", "enum", "text", "weekdays"})
# Named weekday recurrences -> cron day-of-week field.
WEEKDAY_PRESETS: Dict[str, str] = {
"everyday": "*",
"weekdays": "1-5",
"weekends": "0,6",
}
@dataclass(frozen=True)
class BlueprintSlot:
"""A single fillable field on a blueprint."""
name: str
type: str
label: str
default: Any = None
options: tuple = () # for type="enum": allowed values
optional: bool = False
help: str = ""
# When False, ``options`` are suggestions rather than a closed set —
# any value is accepted (e.g. the deliver slot, where the real set of
# valid platforms depends on the user's configured gateways and is
# validated downstream by the cron scheduler).
strict: bool = True
def __post_init__(self) -> None:
if self.type not in _SLOT_TYPES:
raise ValueError(f"unknown slot type {self.type!r} (slot {self.name})")
@dataclass(frozen=True)
class AutomationBlueprint:
"""A parameterized automation blueprint."""
key: str
title: str
description: str
category: str
# Cron expression with ``{slot}`` placeholders, e.g. "{minute} {hour} * * {dow}".
# Placeholders are filled from resolved slot values (time -> minute/hour,
# weekdays -> dow). A literal cron string with no placeholders = fixed schedule.
schedule_template: str
# Seed instruction for the agent / the cron job prompt; may contain {slot}s.
prompt_template: str
slots: List[BlueprintSlot] = field(default_factory=list)
deliver_default: str = "origin"
skills: tuple = () # skills the job loads before running
tags: tuple = ()
# ---------------------------------------------------------------------------
# Curated in-repo catalog
# ---------------------------------------------------------------------------
_TIME = lambda default="08:00": BlueprintSlot( # noqa: E731 - concise factory
name="time", type="time", label="What time?", default=default,
help="24h local time, e.g. 08:00",
)
_DELIVER = BlueprintSlot(
name="deliver", type="enum", label="Where to deliver?",
default="origin", options=("origin", "local", "telegram", "discord", "email"),
optional=False, strict=False,
help="origin = the chat you set this up from (or your configured home "
"channel when created from the dashboard); local = save only, no message; "
"or any connected platform name",
)
CATALOG: List[AutomationBlueprint] = [
AutomationBlueprint(
key="morning-brief",
title="Morning briefing",
description="A short daily briefing: today's calendar, weather, and "
"anything urgent waiting on you.",
category="daily",
schedule_template="{minute} {hour} * * *",
prompt_template=(
"Produce a concise morning briefing for the user: today's calendar "
"events, the local weather, and any urgent items. When Gmail/Google "
"Calendar are connected, follow the google-workspace skill's "
"references/daily-brief.md procedure (exact day window, conflict "
"detection, meeting prep, mail-to-meeting links). Keep it short and "
"scannable. If no data sources are connected, give a brief "
"good-morning with the date and offer to connect calendar/email."
),
slots=[_TIME("08:00"), _DELIVER],
skills=("google-workspace",),
tags=("daily", "briefing"),
),
AutomationBlueprint(
key="important-mail",
title="Important-mail monitor",
description="Check your inbox periodically and ping you ONLY about mail "
"that actually needs attention.",
category="email",
schedule_template="*/{interval_min} * * * *",
prompt_template=(
"Check the user's inbox for new messages since the last run. Surface "
"ONLY mail matching: {criteria}. Score candidates with the urgency "
"classifier and deliver only what clears the bar; if nothing does, "
"respond with [SILENT]. Requires a connected mail source; if none is "
"configured, explain how to connect one and stop."
),
slots=[
BlueprintSlot(
name="interval_min", type="enum", label="How often?",
default="30", options=("15", "30", "60"),
help="minutes between checks",
),
BlueprintSlot(
name="criteria", type="text",
label="Only notify me if the mail…",
default="needs a reply today, is from my manager or family, "
"or mentions a deadline",
),
_DELIVER,
],
skills=("email-inbox-triage",),
tags=("email", "monitor"),
),
AutomationBlueprint(
key="weekly-review",
title="Weekly review",
description="A weekly recap: what got done, what's still open, and "
"what's coming up.",
category="weekly",
schedule_template="{minute} {hour} * * {dow}",
prompt_template=(
"Run the weekly-review-planning skill's procedure for the user: "
"review the completed week and coming 1-2 weeks across connected "
"calendar, tasks, notes, and email; surface commitments, stalled "
"projects, and waiting items; build a capacity-aware plan for next "
"week. Recommendations and drafts only — no mutations without "
"approval. Keep the output in the skill's seven-section shape."
),
slots=[
_TIME("18:00"),
BlueprintSlot(
name="day", type="enum", label="Which day?",
default="sunday",
options=("sunday", "monday", "friday", "saturday"),
),
_DELIVER,
],
skills=("weekly-review-planning",),
tags=("weekly", "review"),
),
AutomationBlueprint(
key="workday-start",
title="Workday start reminder",
description="A weekday nudge with your agenda and top priorities.",
category="daily",
schedule_template="{minute} {hour} * * 1-5",
prompt_template=(
"Give the user a brief weekday start-of-day nudge: today's calendar "
"and the 1-3 highest-priority things to focus on, inferred from "
"recent context and any task tools. Encouraging, short, one message."
),
slots=[_TIME("09:00"), _DELIVER],
tags=("daily", "focus"),
),
AutomationBlueprint(
key="custom-reminder",
title="Custom reminder",
description="A recurring reminder in your own words, on your schedule.",
category="general",
schedule_template="{minute} {hour} * * {dow}",
prompt_template="Remind the user: {what}",
slots=[
BlueprintSlot(name="what", type="text", label="Remind me to…",
default="take a break and stretch"),
_TIME("14:00"),
BlueprintSlot(
name="recurrence", type="weekdays", label="Repeat on",
default="everyday",
options=tuple(WEEKDAY_PRESETS.keys()),
),
_DELIVER,
],
tags=("reminder",),
),
AutomationBlueprint(
key="evening-winddown",
title="Evening wind-down",
description="An end-of-day check-in: tomorrow's calendar at a glance "
"and anything you should prep tonight.",
category="daily",
schedule_template="{minute} {hour} * * *",
prompt_template=(
"Give the user a short evening wind-down: tomorrow's calendar, any "
"early commitments to prep for, and one gentle nudge to wrap up "
"loose ends from today. Keep it calm and brief — one message. If no "
"calendar is connected, just offer a friendly sign-off and the "
"weather for tomorrow."
),
slots=[_TIME("21:00"), _DELIVER],
tags=("daily", "evening"),
),
AutomationBlueprint(
key="news-digest",
title="Topic news digest",
description="A recurring digest on a topic you care about — deduped "
"against what was already sent, so only genuinely new items land.",
category="general",
schedule_template="{minute} {hour} * * {dow}",
prompt_template=(
"Search the web for new and noteworthy items about: {topic}. "
"Dedupe against what you sent in previous runs — only include "
"genuinely new developments. Deliver a tight digest of at most "
"{count} bullets, each one line with a link. If nothing new since "
"last run, respond with [SILENT]."
),
slots=[
BlueprintSlot(
name="topic", type="text", label="What topic?",
default="AI and technology",
help="a subject, product, person, or search phrase",
),
_TIME("18:00"),
BlueprintSlot(
name="recurrence", type="weekdays", label="Repeat on",
default="weekdays",
options=tuple(WEEKDAY_PRESETS.keys()),
),
BlueprintSlot(
name="count", type="enum", label="How many bullets?",
default="5", options=("3", "5", "8"),
),
_DELIVER,
],
tags=("digest", "research"),
),
AutomationBlueprint(
key="bill-renewal-watch",
title="Bills & renewals reminder",
description="A heads-up before a recurring payment, subscription "
"renewal, or due date — so nothing auto-charges by surprise.",
category="general",
schedule_template="{minute} {hour} * * {dow}",
prompt_template=(
"Remind the user about an upcoming payment or renewal: {what}. "
"Phrase it as an actionable heads-up (e.g. 'review or cancel before "
"it renews'), not just a notification. One short message."
),
slots=[
BlueprintSlot(
name="what", type="text", label="What's due?",
default="my streaming subscription renews soon",
),
_TIME("10:00"),
BlueprintSlot(
name="recurrence", type="weekdays", label="Repeat on",
default="everyday",
options=tuple(WEEKDAY_PRESETS.keys()),
),
_DELIVER,
],
tags=("reminder", "finance"),
),
AutomationBlueprint(
key="price-watch",
title="Price & availability watch",
description="Watch an exact product, flight, hotel, or listing and "
"alert when your price or availability condition is met.",
category="general",
schedule_template="0 */{interval_h} * * *",
prompt_template=(
"Load the product-price-monitor skill and run the tick for this "
"watch: {item}. Alert condition: {condition}. Compare the "
"normalized all-in price/availability against stored state, "
"suppress duplicate alerts, and never overwrite last-known-good "
"state with a failed fetch. If no condition is met, respond with "
"[SILENT]. On the first run, execute the skill's setup phase "
"first: pin the exact item, verify one live fetch, and write the "
"watch contract state file."
),
slots=[
BlueprintSlot(
name="item", type="text", label="What exactly to watch?",
default="a product URL or exact flight/hotel/listing description",
help="URL or precise description — variant, dates, seller",
),
BlueprintSlot(
name="condition", type="text", label="Alert me when…",
default="the all-in price drops below my target",
help="threshold price (state the currency), availability, or terms change",
),
BlueprintSlot(
name="interval_h", type="enum", label="How often?",
default="6", options=("1", "3", "6", "12", "24"),
help="hours between checks — be gentle with rate limits",
),
_DELIVER,
],
skills=("product-price-monitor",),
tags=("prices", "shopping", "travel", "monitor"),
),
AutomationBlueprint(
key="competitor-watch",
title="Competitor news watch",
description="Track named companies for material news — launches, "
"pricing, funding, filings — with a cited digest.",
category="general",
schedule_template="{minute} {hour} * * {dow}",
prompt_template=(
"Load the competitor-news-monitor skill and run the tick for this "
"watch: companies {companies}; event categories {categories}. "
"Collect incrementally from the last cutoff, deduplicate by "
"underlying event, score materiality against the watch contract, "
"and deliver a cited digest of material events only. If there are "
"no material events, respond with [SILENT]. On the first run, "
"execute the skill's setup phase first: freeze the watchlist, "
"build source coverage, and write the watch contract state file."
),
slots=[
BlueprintSlot(
name="companies", type="text", label="Which companies?",
default="two or three competitors, by canonical name",
help="canonical names and domains; aliases help dedup",
),
BlueprintSlot(
name="categories", type="text", label="Which events matter?",
default="product launches, pricing changes, funding, "
"partnerships, executive moves, incidents",
),
_TIME("09:00"),
BlueprintSlot(
name="recurrence", type="weekdays", label="Repeat on",
default="monday",
options=tuple(WEEKDAY_PRESETS.keys()),
),
_DELIVER,
],
skills=("competitor-news-monitor",),
tags=("competitors", "news", "monitor", "research"),
),
AutomationBlueprint(
key="habit-checkin",
title="Habit check-in",
description="A recurring nudge to keep a habit on track and reflect "
"on whether you did it.",
category="general",
schedule_template="{minute} {hour} * * {dow}",
prompt_template=(
"Nudge the user about their habit: {habit}. Ask whether they did it "
"today, keep it warm and non-judgmental, and offer a one-line word "
"of encouragement. One short message."
),
slots=[
BlueprintSlot(
name="habit", type="text", label="Which habit?",
default="20 minutes of reading",
),
_TIME("20:00"),
BlueprintSlot(
name="recurrence", type="weekdays", label="Repeat on",
default="everyday",
options=tuple(WEEKDAY_PRESETS.keys()),
),
_DELIVER,
],
tags=("habit", "wellbeing"),
),
AutomationBlueprint(
key="hydration-move",
title="Hydration & movement nudge",
description="A periodic nudge during the day to drink water, stand up, "
"and stretch.",
category="general",
# NOTE: cron minute-field steps (*/90) wrap per hour — */90 and */120
# both degrade to hourly. Use an hour-field step instead so the chosen
# cadence is what actually fires.
schedule_template="0 {start_hour}-{end_hour}/{interval_hours} * * 1-5",
prompt_template=(
"Send the user a brief, friendly nudge to drink some water, stand "
"up, and stretch for a moment. Vary the wording each time so it "
"doesn't feel robotic. One short line."
),
slots=[
BlueprintSlot(
name="interval_hours", type="enum", label="How often?",
default="1", options=("1", "2", "3"),
help="hours between nudges",
),
BlueprintSlot(
name="start_hour", type="enum", label="Start hour",
default="9", options=("7", "8", "9", "10"),
help="first hour of the active window (24h)",
),
BlueprintSlot(
name="end_hour", type="enum", label="End hour",
default="17", options=("16", "17", "18", "19"),
help="last hour of the active window (24h)",
),
_DELIVER,
],
tags=("wellbeing", "focus"),
),
AutomationBlueprint(
key="meal-plan",
title="Weekly meal plan",
description="A weekly meal plan plus a consolidated grocery list, "
"tuned to your diet and how much time you have to cook.",
category="weekly",
schedule_template="{minute} {hour} * * {dow}",
prompt_template=(
"Build the user a meal plan for the coming week: {meals} per day, "
"suited to a {diet} diet and roughly {effort} cooking effort. "
"Include a consolidated grocery list grouped by aisle. Keep blueprints "
"simple and skimmable."
),
slots=[
BlueprintSlot(
name="diet", type="enum", label="Diet?",
default="no restrictions",
options=("no restrictions", "vegetarian", "vegan",
"high-protein", "low-carb"),
),
BlueprintSlot(
name="meals", type="enum", label="Meals per day?",
default="dinner only",
options=("dinner only", "lunch and dinner", "all three"),
),
BlueprintSlot(
name="effort", type="enum", label="Cooking effort?",
default="quick", options=("quick", "medium", "ambitious"),
),
_TIME("17:00"),
BlueprintSlot(
name="day", type="enum", label="Which day?",
default="sunday",
options=("sunday", "monday", "friday", "saturday"),
),
_DELIVER,
],
tags=("weekly", "food"),
),
AutomationBlueprint(
key="learn-daily",
title="Daily learning drip",
description="One bite-sized lesson a day on a topic you want to learn, "
"building progressively over time.",
category="daily",
schedule_template="{minute} {hour} * * {dow}",
prompt_template=(
"Teach the user one bite-sized lesson about: {topic}. Build on "
"earlier lessons so it progresses rather than repeating. Keep it to "
"a couple of short paragraphs with one concrete example, and end "
"with a single question to check understanding."
),
slots=[
BlueprintSlot(
name="topic", type="text", label="Learn about…",
default="Spanish vocabulary",
),
_TIME("08:30"),
BlueprintSlot(
name="recurrence", type="weekdays", label="Repeat on",
default="weekdays",
options=tuple(WEEKDAY_PRESETS.keys()),
),
_DELIVER,
],
tags=("learning", "daily"),
),
AutomationBlueprint(
key="gratitude-journal",
title="Gratitude & reflection prompt",
description="A gentle evening prompt to reflect on the day and note "
"what went well.",
category="general",
schedule_template="{minute} {hour} * * {dow}",
prompt_template=(
"Send the user a short, warm reflection prompt for the end of the "
"day — invite them to note one thing that went well, one thing they "
"are grateful for, and one small win. If they reply, acknowledge it "
"kindly. One message."
),
slots=[
_TIME("21:30"),
BlueprintSlot(
name="recurrence", type="weekdays", label="Repeat on",
default="everyday",
options=tuple(WEEKDAY_PRESETS.keys()),
),
_DELIVER,
],
tags=("wellbeing", "reflection"),
),
AutomationBlueprint(
key="on-this-day",
title="On-this-day discovery",
description="A daily dose of curiosity: a notable historical event, "
"fact, or word for the day.",
category="daily",
schedule_template="{minute} {hour} * * *",
prompt_template=(
"Give the user one interesting '{flavor}' item for today — keep it "
"short, surprising, and genuinely interesting. One or two sentences, "
"no filler."
),
slots=[
BlueprintSlot(
name="flavor", type="enum", label="What kind?",
default="on this day in history",
options=("on this day in history", "word of the day",
"science fact", "quote of the day"),
),
_TIME("07:30"),
_DELIVER,
],
tags=("daily", "curiosity"),
),
]
_CATALOG_BY_KEY = {r.key: r for r in CATALOG}
def get_blueprint(key: str) -> Optional[AutomationBlueprint]:
return _CATALOG_BY_KEY.get(key)
# ---------------------------------------------------------------------------
# Renderers
# ---------------------------------------------------------------------------
def blueprint_form_schema(blueprint: AutomationBlueprint) -> Dict[str, Any]:
"""Emit the JSON a form renderer (dashboard / GUI) needs for this blueprint."""
return {
"key": blueprint.key,
"title": blueprint.title,
"description": blueprint.description,
"category": blueprint.category,
"tags": list(blueprint.tags),
"fields": [
{
"name": s.name,
"type": s.type,
"label": s.label,
"default": s.default,
"options": list(s.options),
"optional": s.optional,
"strict": s.strict,
"help": s.help,
}
for s in blueprint.slots
],
}
def blueprint_slash_command(blueprint: AutomationBlueprint, values: Optional[Dict[str, Any]] = None) -> str:
"""Build the flattened ``/blueprint <key> slot=val …`` command string.
Uses each slot's default when ``values`` is omitted, so the docs/dashboard
can show a ready-to-paste command. Free-text slots are quoted.
"""
values = values or {}
parts = [f"/blueprint {blueprint.key}"]
for s in blueprint.slots:
val = values.get(s.name, s.default)
if val is None or val == "":
if s.optional:
continue
val = ""
sval = str(val)
if s.type == "text" or " " in sval:
sval = '"' + sval.replace('"', '\\"') + '"'
parts.append(f"{s.name}={sval}")
return " ".join(parts)
def blueprint_deeplink(blueprint: AutomationBlueprint, values: Optional[Dict[str, Any]] = None) -> str:
"""Build the ``hermes://blueprint/<key>?slot=val`` deep-link URL."""
from urllib.parse import quote, urlencode
values = values or {}
query = {}
for s in blueprint.slots:
val = values.get(s.name, s.default)
if val not in (None, ""):
query[s.name] = str(val)
qs = ("?" + urlencode(query)) if query else ""
return f"hermes://blueprint/{quote(blueprint.key)}{qs}"
def _humanize_schedule(blueprint: AutomationBlueprint) -> str:
"""A short human-readable description of when a blueprint runs (defaults)."""
sched = blueprint.schedule_template
if sched.startswith("*/"):
iv = next((s for s in blueprint.slots if s.name == "interval_min"), None)
every = (iv.default if iv else None) or sched.split("/")[1].split()[0]
return f"every {every} minutes"
if "{interval_hours}" in sched:
iv = next((s for s in blueprint.slots if s.name == "interval_hours"), None)
every = str((iv.default if iv else None) or "1")
scope = "weekdays, " if "* * 1-5" in sched else ""
return f"{scope}every hour" if every == "1" else f"{scope}every {every} hours"
time_slot = next((s for s in blueprint.slots if s.type == "time"), None)
when = time_slot.default if time_slot else None
if "* * 1-5" in sched:
return f"weekdays at {when}" if when else "every weekday"
if "{dow}" in sched:
day_slot = next((s for s in blueprint.slots if s.name in ("day", "recurrence")), None)
scope = (day_slot.default if day_slot else "") or ""
if scope and when:
return f"{scope} at {when}"
return f"at {when}" if when else "on a schedule"
if when:
return f"daily at {when}"
return "on a schedule"
def blueprint_catalog_entry(blueprint: AutomationBlueprint) -> Dict[str, Any]:
"""Unified serializable shape for a blueprint — used by the docs generator
and the dashboard API. Combines the form schema, the ready-to-paste slash
command, the deep-link URL, and a human-readable schedule.
"""
return {
**blueprint_form_schema(blueprint),
"schedule": blueprint.schedule_template,
"scheduleHuman": _humanize_schedule(blueprint),
"command": blueprint_slash_command(blueprint),
"appUrl": blueprint_deeplink(blueprint),
}
# ---------------------------------------------------------------------------
# Fill + validate + translate to a create_job spec
# ---------------------------------------------------------------------------
_TIME_RE = re.compile(r"^([01]?\d|2[0-3]):([0-5]\d)$")
_DAY_TO_DOW = {
"sunday": "0", "monday": "1", "tuesday": "2", "wednesday": "3",
"thursday": "4", "friday": "5", "saturday": "6",
}
def _resolve_schedule(blueprint: AutomationBlueprint, values: Dict[str, Any]) -> str:
"""Fill the schedule_template placeholders from resolved slot values."""
sched = blueprint.schedule_template
# A free-text `schedule` slot passes through verbatim (full flexibility).
if "schedule" in values and values["schedule"]:
return str(values["schedule"])
repl: Dict[str, str] = {}
# time -> minute/hour
time_val = values.get("time")
if "{minute}" in sched or "{hour}" in sched:
if not time_val:
raise BlueprintFillError("a time is required")
m = _TIME_RE.match(str(time_val).strip())
if not m:
raise BlueprintFillError(f"invalid time {time_val!r} — use HH:MM (24h)")
repl["hour"] = str(int(m.group(1)))
repl["minute"] = str(int(m.group(2)))
# weekday set -> dow
if "{dow}" in sched:
if "recurrence" in values:
preset = str(values.get("recurrence", "everyday")).lower()
if preset not in WEEKDAY_PRESETS:
raise BlueprintFillError(
f"unknown recurrence {preset!r} — one of {', '.join(WEEKDAY_PRESETS)}"
)
repl["dow"] = WEEKDAY_PRESETS[preset]
elif "day" in values:
day = str(values.get("day", "")).lower()
if day not in _DAY_TO_DOW:
raise BlueprintFillError(f"unknown day {day!r}")
repl["dow"] = _DAY_TO_DOW[day]
else:
repl["dow"] = "*"
# interval (minutes) for */N schedules
if "{interval_min}" in sched:
iv = str(values.get("interval_min", "")).strip()
if not iv.isdigit() or int(iv) <= 0:
raise BlueprintFillError(f"invalid interval {iv!r} — minutes as a positive integer")
repl["interval_min"] = iv
# Any remaining {slot} placeholders are filled verbatim from validated
# enum/text slot values (e.g. an hour-range window). Enum options have
# already been checked in fill_blueprint, so these are safe to interpolate.
for name in re.findall(r"\{(\w+)\}", sched):
if name not in repl and name in values:
repl[name] = str(values[name])
try:
return sched.format(**repl)
except KeyError as e: # pragma: no cover - template/slot mismatch is a dev error
raise BlueprintFillError(f"schedule template missing value for {e}") from e
def fill_blueprint(
blueprint: AutomationBlueprint,
values: Dict[str, Any],
*,
origin: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""Validate ``values`` and return ``cron.jobs.create_job`` kwargs.
Missing required (non-optional) slots raise BlueprintFillError naming the
slot, so a form can show field errors and the agent knows what to ask.
Unknown slot names are rejected (a typo'd ``tiem=07:15`` must not silently
create a job with the default time). Enum values are checked against their
options. The result is passed straight to ``create_job`` — no second schema.
"""
known = {s.name for s in blueprint.slots}
unknown = sorted(set(values) - known)
if unknown:
raise BlueprintFillError(
f"unknown slot{'s' if len(unknown) > 1 else ''}: "
f"{', '.join(unknown)} — valid: {', '.join(s.name for s in blueprint.slots)}"
)
resolved: Dict[str, Any] = {}
for s in blueprint.slots:
raw = values.get(s.name, s.default)
if raw in (None, ""):
if s.optional:
continue
raise BlueprintFillError(f"missing required value: {s.name} ({s.label})")
if s.type == "enum" and s.strict and s.options and str(raw) not in {str(o) for o in s.options}:
raise BlueprintFillError(
f"{s.name}={raw!r} not allowed — one of {', '.join(map(str, s.options))}"
)
resolved[s.name] = raw
schedule = _resolve_schedule(blueprint, resolved)
# Render the prompt with whatever slots it references.
try:
prompt = blueprint.prompt_template.format(**resolved)
except KeyError as e:
raise BlueprintFillError(f"blueprint prompt missing value for {e}") from e
spec: Dict[str, Any] = {
"prompt": prompt,
"schedule": schedule,
"name": blueprint.title,
"deliver": resolved.get("deliver", blueprint.deliver_default),
}
if blueprint.skills:
spec["skills"] = list(blueprint.skills)
if origin is not None:
spec["origin"] = origin
return spec
+379
View File
@@ -0,0 +1,379 @@
"""Profile-local durable handoff for cron delivery through live gateway adapters.
A restart-safe cron worker executes outside the gateway cgroup. It cannot own
relay/E2EE adapter objects, so it queues the final send here. A gateway claims
each row at most once. If that gateway dies after claiming, the outcome is
marked unknown and never retried: losing a delivery is safer than duplicating a
possibly-completed send.
"""
from __future__ import annotations
import json
import logging
import os
import sqlite3
import threading
import time
import uuid
from contextlib import contextmanager
from pathlib import Path
from typing import Any, Callable, Iterator, Optional
from agent.redact import redact_sensitive_text
from cron.executions import _owner_is_live, _process_start_time
from hermes_cli.sqlite_util import add_column_if_missing
from hermes_constants import get_hermes_home
from hermes_time import now as _hermes_now
logger = logging.getLogger(__name__)
DELIVERY_DB: Optional[Path] = None
_PROCESS_ID = uuid.uuid4().hex
_lock = threading.RLock()
_ACTIVE_DELIVERIES: set[str] = set()
_TERMINAL = ("delivered", "failed", "unknown")
MAX_TERMINAL_DELIVERIES = 1000
DEFAULT_DELIVERY_WAIT_TIMEOUT_SECONDS = 300.0
def _prune_terminal_unlocked(conn: sqlite3.Connection) -> None:
"""Redact terminal payloads and retain only bounded outcome metadata."""
conn.execute(
"""UPDATE deliveries SET job_json='{}', content=''
WHERE status IN ('delivered','failed','unknown')
AND (job_json != '{}' OR content != '')"""
)
keep = max(0, int(MAX_TERMINAL_DELIVERIES))
terminal_count = int(
conn.execute(
"SELECT COUNT(*) FROM deliveries "
"WHERE status IN ('delivered','failed','unknown')"
).fetchone()[0]
)
excess = terminal_count - keep
if excess > 0:
conn.execute(
"""INSERT OR IGNORE INTO delivery_tombstones
(execution_id, terminal_status, finished_at)
SELECT execution_id, status, finished_at FROM deliveries
WHERE status IN ('delivered','failed','unknown')
ORDER BY finished_at, created_at, execution_id
LIMIT ?""",
(excess,),
)
conn.execute(
"""DELETE FROM deliveries WHERE execution_id IN (
SELECT execution_id FROM deliveries
WHERE status IN ('delivered','failed','unknown')
ORDER BY finished_at, created_at, execution_id
LIMIT ?
)""",
(excess,),
)
def _path() -> Path:
return DELIVERY_DB or (get_hermes_home().resolve() / "cron" / "deliveries.db")
@contextmanager
def _transaction() -> Iterator[sqlite3.Connection]:
with _lock:
path = _path()
path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(path, timeout=5)
try:
path.chmod(0o600)
except OSError:
pass
conn.row_factory = sqlite3.Row
try:
from hermes_state import apply_wal_with_fallback
conn.execute("PRAGMA busy_timeout=5000")
apply_wal_with_fallback(conn, db_label="cron/deliveries.db")
conn.execute("PRAGMA synchronous=FULL")
conn.execute(
"""CREATE TABLE IF NOT EXISTS deliveries (
execution_id TEXT PRIMARY KEY,
job_json TEXT NOT NULL,
content TEXT NOT NULL,
for_failure INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL CHECK(status IN
('pending','delivering','delivered','failed','unknown')),
owner_process_id TEXT,
owner_pid INTEGER,
owner_started_at INTEGER,
created_at TEXT NOT NULL,
finished_at TEXT,
error TEXT
)"""
)
conn.execute(
"""CREATE TABLE IF NOT EXISTS delivery_tombstones (
execution_id TEXT PRIMARY KEY,
terminal_status TEXT NOT NULL CHECK(terminal_status IN
('delivered','failed','unknown')),
finished_at TEXT
)"""
)
add_column_if_missing(
conn, "deliveries", "for_failure",
"for_failure INTEGER NOT NULL DEFAULT 0",
)
# Pruning is done explicitly by the paths that create terminal
# rows (_finish / recover_abandoned / _terminalize_wait_timeout);
# read-only polls must not pay for a full-table UPDATE + COUNT.
with conn:
yield conn
finally:
conn.close()
def enqueue(
execution_id: str,
job: dict,
content: str,
*,
for_failure: bool = False,
) -> dict:
"""Persist one idempotent delivery request before the worker waits."""
with _transaction() as conn:
tombstone = conn.execute(
"SELECT terminal_status, finished_at FROM delivery_tombstones "
"WHERE execution_id=?",
(str(execution_id),),
).fetchone()
if tombstone is not None:
return {
"execution_id": str(execution_id),
"status": tombstone["terminal_status"],
"finished_at": tombstone["finished_at"],
}
conn.execute(
"""INSERT OR IGNORE INTO deliveries
(execution_id, job_json, content, for_failure, status, created_at)
VALUES (?, ?, ?, ?, 'pending', ?)""",
(
str(execution_id),
json.dumps(job, ensure_ascii=False, sort_keys=True),
str(content),
int(bool(for_failure)),
_hermes_now().isoformat(),
),
)
row = conn.execute(
"SELECT * FROM deliveries WHERE execution_id=?", (str(execution_id),)
).fetchone()
return dict(row)
def get_status(execution_id: str) -> Optional[dict]:
with _transaction() as conn:
row = conn.execute(
"SELECT * FROM deliveries WHERE execution_id=?", (str(execution_id),)
).fetchone()
if row is not None:
return dict(row)
tombstone = conn.execute(
"SELECT execution_id, terminal_status, finished_at "
"FROM delivery_tombstones WHERE execution_id=?",
(str(execution_id),),
).fetchone()
if tombstone is None:
return None
return {
"execution_id": tombstone["execution_id"],
"status": tombstone["terminal_status"],
"finished_at": tombstone["finished_at"],
"error": None,
}
def claim_next() -> Optional[dict]:
"""Atomically claim one pending send before touching the transport."""
pid = os.getpid()
started = _process_start_time(pid)
with _transaction() as conn:
row = conn.execute(
"SELECT execution_id FROM deliveries WHERE status='pending' "
"ORDER BY created_at, execution_id LIMIT 1"
).fetchone()
if row is None:
return None
cur = conn.execute(
"""UPDATE deliveries SET status='delivering', owner_process_id=?,
owner_pid=?, owner_started_at=?
WHERE execution_id=? AND status='pending'""",
(_PROCESS_ID, pid, started, row["execution_id"]),
)
if cur.rowcount != 1:
return None
claimed = conn.execute(
"SELECT * FROM deliveries WHERE execution_id=?", (row["execution_id"],)
).fetchone()
_ACTIVE_DELIVERIES.add(row["execution_id"])
result = dict(claimed)
result["job"] = json.loads(result.pop("job_json"))
return result
def _finish(execution_id: str, *, error: Optional[str]) -> bool:
status = "failed" if error else "delivered"
safe_error = (
redact_sensitive_text(str(error), force=True, redact_url_credentials=True)
if error
else None
)
with _transaction() as conn:
cur = conn.execute(
"""UPDATE deliveries SET status=?, finished_at=?, error=?
WHERE execution_id=? AND status='delivering'
AND owner_process_id=? AND owner_pid=?""",
(
status,
_hermes_now().isoformat(),
safe_error,
execution_id,
_PROCESS_ID,
os.getpid(),
),
)
_prune_terminal_unlocked(conn)
return cur.rowcount == 1
def recover_abandoned() -> int:
"""Fence dead delivery owners as unknown; never replay uncertain sends."""
changed = 0
with _transaction() as conn:
rows = conn.execute(
"SELECT execution_id, owner_process_id, owner_pid, owner_started_at "
"FROM deliveries WHERE status='delivering'"
).fetchall()
for row in rows:
same_process = row["owner_process_id"] == _PROCESS_ID
if same_process:
with _lock:
if row["execution_id"] in _ACTIVE_DELIVERIES:
continue
elif _owner_is_live(int(row["owner_pid"]), row["owner_started_at"]):
continue
error = (
"Gateway finished delivery but could not persist its outcome; "
"send was not retried."
if same_process
else "Gateway exited during delivery; send outcome is unknown and was not retried."
)
cur = conn.execute(
"""UPDATE deliveries SET status='unknown', finished_at=?, error=?
WHERE execution_id=? AND status='delivering'""",
(
_hermes_now().isoformat(),
error,
row["execution_id"],
),
)
changed += cur.rowcount
_prune_terminal_unlocked(conn)
return changed
def drain(
send: Callable[[dict, str, bool], Optional[str]], *, limit: int = 20
) -> int:
"""Deliver pending rows through *send*, terminalizing every claimed row."""
recover_abandoned()
processed = 0
for _ in range(max(0, limit)):
row = claim_next()
if row is None:
break
with _lock:
_ACTIVE_DELIVERIES.add(row["execution_id"])
try:
try:
error = send(
row["job"], row["content"], bool(row["for_failure"])
)
except BaseException as exc:
error = f"{type(exc).__name__}: {exc}"
_finish(row["execution_id"], error=error)
finally:
with _lock:
_ACTIVE_DELIVERIES.discard(row["execution_id"])
processed += 1
return processed
def _terminalize_wait_timeout(execution_id: str) -> str:
"""Fence a delivery whose worker can no longer wait for confirmation.
A row still ``pending`` was provably never attempted, so it is left queued
for whichever gateway comes up next (a restart that includes an update can
easily exceed the worker's wait budget). That is a deferral, not a
failure: report success so the job is not recorded ``delivery_failed`` for
a message the drain will still send. Only a row caught mid-send is
uncertain and gets fenced ``unknown``.
"""
now = _hermes_now().isoformat()
uncertain_error = (
"timed out while gateway delivery was in progress; outcome is unknown and "
"was not retried"
)
with _transaction() as conn:
row = conn.execute(
"SELECT status FROM deliveries WHERE execution_id=?",
(str(execution_id),),
).fetchone()
if row is not None and row["status"] == "pending":
logger.warning(
"Cron delivery %s: no live gateway within the wait budget; "
"left queued for the next gateway",
execution_id,
)
return ""
conn.execute(
"""UPDATE deliveries SET status='unknown', finished_at=?, error=?
WHERE execution_id=? AND status='delivering'""",
(now, uncertain_error, str(execution_id)),
)
row = conn.execute(
"SELECT status, error FROM deliveries WHERE execution_id=?",
(str(execution_id),),
).fetchone()
_prune_terminal_unlocked(conn)
if row is None:
return "timed out waiting for live gateway delivery"
if row["status"] == "delivered":
return ""
return str(row["error"] or f"delivery {row['status']}")
def enqueue_and_wait(
execution_id: str,
job: dict,
content: str,
*,
for_failure: bool = False,
timeout: Optional[float] = None,
) -> Optional[str]:
"""Queue delivery and wait for a gateway's terminal at-most-once outcome."""
queued = enqueue(execution_id, job, content, for_failure=for_failure)
if queued["status"] in _TERMINAL:
return None if queued["status"] == "delivered" else str(
queued.get("error") or f"delivery {queued['status']}"
)
wait_timeout = (
DEFAULT_DELIVERY_WAIT_TIMEOUT_SECONDS if timeout is None else max(0.0, timeout)
)
deadline = time.monotonic() + wait_timeout
while time.monotonic() < deadline:
row = get_status(execution_id)
if row and row["status"] in _TERMINAL:
return None if row["status"] == "delivered" else str(
row.get("error") or f"delivery {row['status']}"
)
time.sleep(1.0)
return _terminalize_wait_timeout(execution_id) or None
+377
View File
@@ -0,0 +1,377 @@
"""Profile-local durable audit ledger for cron execution attempts.
The ledger records what is known about each attempt; it is not a retry queue.
Interrupted attempts become ``unknown`` only after their exact owner process is
proved gone. Terminal states are immutable.
"""
from __future__ import annotations
import os
import sqlite3
import threading
import time
import uuid
from contextlib import contextmanager
from typing import Any, Dict, Iterator, List, Optional
from hermes_constants import get_hermes_home
from hermes_time import now as _hermes_now
# Optional test override. Production resolves the path at transaction time so
# dashboard operations that temporarily enter another profile cannot leak that
# profile's execution records into the import-time home.
EXECUTIONS_FILE: Optional[Path] = None
MAX_TERMINAL_EXECUTIONS = 1000
HANDOFF_ADOPTION_GRACE_SECONDS = 30.0
_TERMINAL_STATES = ("completed", "failed", "unknown")
_lock = threading.RLock()
_PROCESS_ID = uuid.uuid4().hex
def _connect() -> sqlite3.Connection:
from cron.jobs import _ensure_cron_dir
path = EXECUTIONS_FILE or (get_hermes_home().resolve() / "cron" / "executions.db")
_ensure_cron_dir(path.parent)
return sqlite3.connect(path, timeout=5)
def _initialize_schema(conn: sqlite3.Connection) -> None:
from hermes_state import apply_wal_with_fallback
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA busy_timeout=5000")
apply_wal_with_fallback(conn, db_label="cron/executions.db")
conn.execute("PRAGMA synchronous=FULL")
conn.execute(
"""CREATE TABLE IF NOT EXISTS executions (
id TEXT PRIMARY KEY,
job_id TEXT NOT NULL,
source TEXT NOT NULL,
process_id TEXT NOT NULL,
pid INTEGER NOT NULL,
process_started_at INTEGER,
status TEXT NOT NULL CHECK(status IN
('claimed','running','completed','failed','unknown')),
handoff_pending INTEGER NOT NULL DEFAULT 0,
handoff_started_at REAL,
claimed_at TEXT NOT NULL,
started_at TEXT,
finished_at TEXT,
error TEXT
)"""
)
from hermes_cli.sqlite_util import add_column_if_missing
add_column_if_missing(
conn, "executions", "handoff_pending",
"handoff_pending INTEGER NOT NULL DEFAULT 0",
)
add_column_if_missing(
conn, "executions", "handoff_started_at", "handoff_started_at REAL"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_executions_job_claimed "
"ON executions(job_id, claimed_at DESC, id DESC)"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_executions_status_claimed "
"ON executions(status, claimed_at DESC, id DESC)"
)
@contextmanager
def _transaction() -> Iterator[sqlite3.Connection]:
"""Open a connection, commit/rollback on exit, always close.
``sqlite3.Connection.__enter__``/``__exit__`` only commit or roll back
the transaction; it does not close the connection. Relying on that alone
leaks a connection (and its WAL/SHM file descriptors) on every call,
since closing then depends on the garbage collector. Schema init runs
inside the ``try`` too, so a PRAGMA/DDL failure after a successful
``connect()`` still closes the connection instead of leaking it.
"""
with _lock:
conn = _connect()
try:
_initialize_schema(conn)
with conn:
yield conn
finally:
conn.close()
def _record(row: Optional[sqlite3.Row]) -> Optional[Dict[str, Any]]:
return dict(row) if row is not None else None
def _emit_execution_state(
record: Optional[Dict[str, Any]], *, delivery_outcome: Optional[str] = None
) -> None:
"""Project durable state to monitoring without affecting ledger behavior."""
try:
from agent.monitoring.cron_health import emit_execution_state
emit_execution_state(record, delivery_outcome=delivery_outcome)
except Exception:
pass
def _process_start_time(pid: int) -> Optional[int]:
try:
from gateway.status import get_process_start_time
return get_process_start_time(pid)
except Exception:
return None
def _owner_is_live(pid: int, started_at: Optional[int]) -> bool:
try:
from gateway.status import _pid_exists
if not _pid_exists(pid):
return False
except Exception:
return True # fail safe: inability to prove death must not rewrite state
if started_at is None:
return pid == os.getpid()
current = _process_start_time(pid)
return current is not None and current == started_at
def _prune_unlocked(conn: sqlite3.Connection) -> None:
limit = max(0, int(MAX_TERMINAL_EXECUTIONS))
conn.execute(
"""DELETE FROM executions WHERE id IN (
SELECT id FROM executions
WHERE status IN ('completed','failed','unknown')
ORDER BY finished_at DESC, claimed_at DESC, id DESC LIMIT -1 OFFSET ?
)""",
(limit,),
)
def create_execution(job_id: str, *, source: str) -> Dict[str, Any]:
"""Persist a claimed attempt before executor/provider dispatch."""
now = _hermes_now().isoformat()
execution_id = uuid.uuid4().hex
pid = os.getpid()
with _transaction() as conn:
conn.execute(
"""INSERT INTO executions
(id, job_id, source, process_id, pid, process_started_at,
status, claimed_at)
VALUES (?, ?, ?, ?, ?, ?, 'claimed', ?)""",
(execution_id, str(job_id), str(source), _PROCESS_ID, pid,
_process_start_time(pid), now),
)
row = conn.execute(
"SELECT * FROM executions WHERE id=?", (execution_id,)
).fetchone()
record = _record(row)
_emit_execution_state(record)
return record # type: ignore[return-value]
def mark_execution_handoff_pending(execution_id: str) -> Optional[Dict[str, Any]]:
"""Fence restart recovery while an external worker is adopting a claim."""
with _transaction() as conn:
cur = conn.execute(
"""UPDATE executions
SET handoff_pending=1, handoff_started_at=?
WHERE id=? AND status='claimed'
AND process_id=? AND pid=?""",
(time.time(), execution_id, _PROCESS_ID, os.getpid()),
)
if cur.rowcount != 1:
return None
record = _record(conn.execute(
"SELECT * FROM executions WHERE id=?", (execution_id,)
).fetchone())
_emit_execution_state(record)
return record
def adopt_claimed_execution(execution_id: str) -> Optional[Dict[str, Any]]:
"""Atomically transfer and start an attempt in its worker process.
The dispatching gateway creates the row before spawning a restart-safe
worker. Adoption is the single ``claimed`` → ``running`` gate: only the
winner may acknowledge ownership or run side effects.
"""
pid = os.getpid()
process_started_at = _process_start_time(pid)
now = _hermes_now().isoformat()
with _transaction() as conn:
cur = conn.execute(
"""UPDATE executions
SET process_id=?, pid=?, process_started_at=?,
status='running', started_at=?, handoff_pending=0,
handoff_started_at=NULL
WHERE id=? AND status='claimed' AND handoff_pending=1""",
(_PROCESS_ID, pid, process_started_at, now, execution_id),
)
if cur.rowcount != 1:
return None
record = _record(conn.execute(
"SELECT * FROM executions WHERE id=?", (execution_id,)
).fetchone())
_emit_execution_state(record)
return record
def mark_execution_running(execution_id: str) -> Optional[Dict[str, Any]]:
"""Transition one claimed attempt to running exactly once."""
now = _hermes_now().isoformat()
with _transaction() as conn:
cur = conn.execute(
"""UPDATE executions
SET status='running', started_at=?, handoff_pending=0,
handoff_started_at=NULL
WHERE id=? AND status='claimed' AND handoff_pending=0
AND process_id=? AND pid=?""",
(now, execution_id, _PROCESS_ID, os.getpid()),
)
if cur.rowcount != 1:
return None
record = _record(conn.execute(
"SELECT * FROM executions WHERE id=?", (execution_id,)
).fetchone())
_emit_execution_state(record)
return record
def finish_execution(
execution_id: str, *, success: bool, error: Optional[str] = None,
delivery_outcome: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
"""Write a terminal result once; terminal attempts cannot be rewritten."""
now = _hermes_now().isoformat()
status = "completed" if success else "failed"
detail = None if success else (str(error) if error else "unknown failure")
with _transaction() as conn:
cur = conn.execute(
"""UPDATE executions
SET status=?, finished_at=?, error=?, handoff_pending=0,
handoff_started_at=NULL
WHERE id=? AND status IN ('claimed','running')
AND process_id=? AND pid=?""",
(status, now, detail, execution_id, _PROCESS_ID, os.getpid()),
)
if cur.rowcount != 1:
return None
_prune_unlocked(conn)
record = _record(conn.execute(
"SELECT * FROM executions WHERE id=?", (execution_id,)
).fetchone())
_emit_execution_state(record, delivery_outcome=delivery_outcome)
return record
def recover_interrupted_executions() -> int:
"""Mark provably abandoned attempts unknown without scheduling retries."""
now = _hermes_now().isoformat()
changed = 0
recovered: List[Dict[str, Any]] = []
with _transaction() as conn:
rows = conn.execute(
"""SELECT id, status, process_id, pid, process_started_at,
handoff_pending, handoff_started_at
FROM executions
WHERE status IN ('claimed','running')"""
).fetchall()
for row in rows:
if row["process_id"] == _PROCESS_ID:
continue
if _owner_is_live(int(row["pid"]), row["process_started_at"]):
continue
handoff_started_at = row["handoff_started_at"]
if (
row["handoff_pending"]
and handoff_started_at is not None
and time.time() - float(handoff_started_at)
< HANDOFF_ADOPTION_GRACE_SECONDS
):
continue
cur = conn.execute(
"""UPDATE executions
SET status='unknown', finished_at=?, error=?,
handoff_pending=0, handoff_started_at=NULL
WHERE id=? AND status=? AND process_id=? AND pid=?
AND handoff_pending=?
AND handoff_started_at IS ?""",
(now,
"Scheduler restarted after this execution's owner exited before a durable "
"terminal state; whether side effects ran is unknown.",
row["id"], row["status"], row["process_id"], row["pid"],
row["handoff_pending"], row["handoff_started_at"]),
)
changed += cur.rowcount
if cur.rowcount:
record = _record(conn.execute(
"SELECT * FROM executions WHERE id=?", (row["id"],)
).fetchone())
if record is not None:
recovered.append(record)
if changed:
_prune_unlocked(conn)
for record in recovered:
_emit_execution_state(record)
return changed
def list_executions(
*, job_id: Optional[str] = None, limit: int = 50,
before_claimed_at: Optional[str] = None,
) -> List[Dict[str, Any]]:
"""Return indexed, newest-first execution history with cursor pagination."""
clauses: List[str] = []
params: List[Any] = []
if job_id is not None:
clauses.append("job_id=?")
params.append(str(job_id))
if before_claimed_at is not None:
clauses.append("claimed_at < ?")
params.append(str(before_claimed_at))
where = " WHERE " + " AND ".join(clauses) if clauses else ""
params.append(max(1, min(int(limit), 500)))
with _transaction() as conn:
rows = conn.execute(
"SELECT * FROM executions" + where
+ " ORDER BY claimed_at DESC, id DESC LIMIT ?",
params,
).fetchall()
return [dict(row) for row in rows]
def get_execution(execution_id: str) -> Optional[Dict[str, Any]]:
"""Return one exact execution attempt, or ``None`` when it is absent."""
with _transaction() as conn:
row = conn.execute(
"SELECT * FROM executions WHERE id=?",
(str(execution_id),),
).fetchone()
return dict(row) if row is not None else None
def latest_execution(job_id: str) -> Optional[Dict[str, Any]]:
rows = list_executions(job_id=job_id, limit=1)
return rows[0] if rows else None
def latest_executions(job_ids: List[str]) -> Dict[str, Dict[str, Any]]:
"""Load latest execution for many jobs in one indexed query."""
clean = [str(job_id) for job_id in dict.fromkeys(job_ids) if job_id]
if not clean:
return {}
placeholders = ",".join("?" for _ in clean)
with _transaction() as conn:
rows = conn.execute(
f"""SELECT e.* FROM executions e
WHERE e.job_id IN ({placeholders})
AND e.id=(SELECT e2.id FROM executions e2
WHERE e2.job_id=e.job_id
ORDER BY e2.claimed_at DESC, e2.id DESC LIMIT 1)""",
clean,
).fetchall()
return {row["job_id"]: dict(row) for row in rows}
+302
View File
@@ -0,0 +1,302 @@
"""Durable cron failure incidents with signature dedup and ack.
The executions ledger (``cron.executions``) records every attempt; this module
groups the *failures* into durable incidents keyed by ``(job_id, error
signature)`` so the same job failing with the same error does not re-ping the
operator every run once they have acknowledged it.
Lifecycle: ``detected`` → ``alerted`` → ``closed``. Closing
(acking) an incident is per-signature: the same job + same normalized error
keeps resolving to the SAME incident id, so a closed incident stays closed (no
re-alert) until the error text changes, which mints a brand-new incident.
``detected`` means the failure was recorded; ``alerted`` means at least one
failure ping for the signature actually reached the operator. Richer states
(e.g. a dv9.6 ``reviewed``) are deliberately NOT reserved here — state
validity lives in ``INCIDENT_STATES`` (Python), not a SQLite CHECK, exactly
so a future slice can add states without a table rebuild.
Incidents live in the SAME ``cron/executions.db`` as ``cron.executions`` so
there is one durable cron store per profile. The schema is lazily created on
connect and a missing database never raises (directories are created).
"""
from __future__ import annotations
import hashlib
import re
import sqlite3
import threading
from contextlib import contextmanager
from pathlib import Path
from typing import Any, Dict, Iterator, List, Optional
from hermes_constants import get_hermes_home
from hermes_time import now as _hermes_now
# Optional test override (mirrors ``cron.executions.EXECUTIONS_FILE``).
EXECUTIONS_FILE: Optional[Path] = None
INCIDENT_STATES = ("detected", "alerted", "closed")
_FAILURE_TYPE_ORDER = (
("rate_limit", (r"\b429\b", "rate limit", "usage limit", "quota")),
("timeout", ("timeout", "timed out")),
("auth", (r"\b401\b", "unauthorized", "authentication", "auth")),
("delivery", ("delivery", "deliver", "delivering")),
("config", ("config", "configuration", "validation")),
("script", ("script", "no_agent")),
("agent", ("agent", "model", "provider", "inference")),
)
MAX_ERROR_CHARS = 500
_MAX_SIGNATURE_ERROR_CHARS = 200
_lock = threading.RLock()
def _connect() -> sqlite3.Connection:
from cron.jobs import _ensure_cron_dir
path = _db_path()
_ensure_cron_dir(path.parent)
return sqlite3.connect(path, timeout=5)
def _db_path() -> Path:
"""Resolve the shared cron DB path.
Prefer the ``cron.executions`` override when one is installed so an
operator/test that redirects the executions ledger also redirects the
incident table — they must stay in the SAME database. Falls back to this
module's own override, then the canonical profile home.
"""
try:
from cron.executions import EXECUTIONS_FILE as _EXEC_OVERRIDE
if _EXEC_OVERRIDE is not None:
return Path(_EXEC_OVERRIDE)
except Exception:
pass
if EXECUTIONS_FILE is not None:
return Path(EXECUTIONS_FILE)
return get_hermes_home().resolve() / "cron" / "executions.db"
def _initialize_schema(conn: sqlite3.Connection) -> None:
from hermes_state import apply_wal_with_fallback
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA busy_timeout=5000")
apply_wal_with_fallback(conn, db_label="cron/executions.db")
conn.execute("PRAGMA synchronous=FULL")
conn.execute(
"""CREATE TABLE IF NOT EXISTS cron_incidents (
id TEXT PRIMARY KEY,
job_id TEXT NOT NULL,
error_sig TEXT NOT NULL,
state TEXT NOT NULL,
failure_type TEXT NOT NULL DEFAULT 'unknown',
first_seen_at TEXT NOT NULL,
last_seen_at TEXT NOT NULL,
acked_at TEXT,
closed_at TEXT,
error TEXT NOT NULL,
output_file TEXT
)"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_cron_incidents_job "
"ON cron_incidents(job_id)"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_cron_incidents_state "
"ON cron_incidents(state)"
)
@contextmanager
def _transaction() -> Iterator[sqlite3.Connection]:
"""Open a connection, commit/rollback on exit, always close.
Mirrors ``cron.executions._transaction``: schema init runs inside the
``try`` so a PRAGMA/DDL failure after a successful ``connect()`` still
closes the connection instead of leaking it.
"""
with _lock:
conn = _connect()
try:
_initialize_schema(conn)
with conn:
yield conn
finally:
conn.close()
def _normalize_error(error: str) -> str:
"""Strip whitespace and lowercase before signing (dedup normalization)."""
return re.sub(r"\s+", " ", str(error or "")).strip().lower()
def _redact_error(error: str) -> str:
"""Redact secrets then bound the stored error length."""
text = str(error or "")
try:
from agent.redact import redact_sensitive_text
text = redact_sensitive_text(text)
except Exception:
# Redaction is best-effort; the scheduler path never fails on it.
pass
return text[:MAX_ERROR_CHARS]
def _error_signature(job_id: str, error: str) -> str:
"""Dedup key: stable for same job + same normalized error prefix."""
normalized = _normalize_error(error)[:_MAX_SIGNATURE_ERROR_CHARS]
digest = hashlib.sha256(job_id.encode() + normalized.encode()).hexdigest()
return digest[:12]
def _incident_id(job_id: str, error_sig: str) -> str:
return f"{job_id[:6]}_{error_sig}"
def _classify_failure_type(error: str) -> str:
"""Classify a failure from error-text keywords; ``unknown`` is the default."""
text = _normalize_error(error)
if not text:
return "unknown"
for kind, patterns in _FAILURE_TYPE_ORDER:
for pattern in patterns:
if pattern.startswith("\\b") and pattern.endswith("\\b"):
if re.search(pattern, text):
return kind
elif pattern in text:
return kind
return "unknown"
def upsert_incident(
job_id: str,
error: str,
*,
job_name: Optional[str] = None,
failure_type: Optional[str] = None,
output_file: Optional[str] = None,
) -> tuple[str, bool]:
"""Record (or refresh) the incident for ``job_id`` + ``error``.
Returns ``(incident_id, is_new)``. A row for the same signature already
existing refreshes ``last_seen_at``/``error``/``output_file`` and keeps its
current state — a ``closed`` (acked) incident stays closed for the same
signature. A changed error text mints a new incident automatically.
"""
job_id = str(job_id or "")
sig = _error_signature(job_id, error)
stored_error = _redact_error(error)
incident_id = _incident_id(job_id, sig)
now = _hermes_now().isoformat()
failure_type = failure_type or _classify_failure_type(error)
output_file = str(output_file) if output_file is not None else None
with _transaction() as conn:
row = conn.execute(
"SELECT id FROM cron_incidents WHERE id=?", (incident_id,)
).fetchone()
if row is not None:
conn.execute(
"""UPDATE cron_incidents
SET last_seen_at=?, error=?, output_file=?
WHERE id=?""",
(now, stored_error, output_file, incident_id),
)
return incident_id, False
conn.execute(
"""INSERT INTO cron_incidents
(id, job_id, error_sig, state, failure_type,
first_seen_at, last_seen_at, error, output_file)
VALUES (?, ?, ?, 'detected', ?, ?, ?, ?, ?)""",
(incident_id, job_id, sig, failure_type, now, now,
stored_error, output_file),
)
return incident_id, True
def set_incident_state(incident_id: str, state: str) -> bool:
"""Transition an incident's lifecycle state; return whether it changed.
``closed`` is terminal for that signature: no transition (including back
to ``alerted``) leaves it — re-open happens by the error changing and
minting a NEW incident. Unknown states are rejected (no-op, ``False``).
"""
if state not in INCIDENT_STATES:
return False
now = _hermes_now().isoformat()
with _transaction() as conn:
row = conn.execute(
"SELECT state FROM cron_incidents WHERE id=?", (incident_id,)
).fetchone()
if row is None or row["state"] == state:
return False
if row["state"] == "closed":
return False
if state == "closed":
conn.execute(
"""UPDATE cron_incidents
SET state='closed', closed_at=?, acked_at=?
WHERE id=? AND state != 'closed'""",
(now, now, incident_id),
)
else:
conn.execute(
"UPDATE cron_incidents SET state=? WHERE id=?",
(state, incident_id),
)
return True
def ack_incident(incident_id: str) -> bool:
"""Acknowledge (close) an incident; return whether the state changed.
A no-op (``False``) when the incident does not exist or is already closed.
"""
return set_incident_state(incident_id, "closed")
def list_incidents(state: Optional[str] = None) -> List[Dict[str, Any]]:
"""Return incidents, newest-activity first, optionally filtered by state."""
if state is not None and state not in INCIDENT_STATES:
return []
with _transaction() as conn:
if state is None:
rows = conn.execute(
"SELECT * FROM cron_incidents "
"ORDER BY last_seen_at DESC, id DESC"
).fetchall()
else:
rows = conn.execute(
"SELECT * FROM cron_incidents WHERE state=? "
"ORDER BY last_seen_at DESC, id DESC",
(state,),
).fetchall()
return [dict(row) for row in rows]
def get_incident(incident_id: str) -> Optional[Dict[str, Any]]:
with _transaction() as conn:
row = conn.execute(
"SELECT * FROM cron_incidents WHERE id=?", (incident_id,)
).fetchone()
return dict(row) if row is not None else None
def count_incidents(state: Optional[str] = None) -> int:
if state is not None and state not in INCIDENT_STATES:
return 0
with _transaction() as conn:
if state is None:
row = conn.execute("SELECT COUNT(*) AS n FROM cron_incidents").fetchone()
else:
row = conn.execute(
"SELECT COUNT(*) AS n FROM cron_incidents WHERE state=?",
(state,),
).fetchone()
return int(row["n"]) if row is not None else 0
+4671
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+213
View File
@@ -0,0 +1,213 @@
"""Monitor-mode cron support — hash-suppressed change detection.
A monitor job attaches a cheap *monitor source* (``monitor_script`` or
``monitor_url``) to an ordinary LLM cron job. Each tick the scheduler runs
the source FIRST and compares a hash of its exact output bytes against the
hash stored from the last agent-triggering tick:
* unchanged → the agent run is suppressed entirely (no LLM, no delivery);
the tick is recorded as a silent ``no_change`` run.
* changed (or first run) → a "MONITOR CHANGE DETECTED" context block —
unified diff of old vs new output (capped) plus the new output — is
injected into the prompt and the agent runs normally.
* source failure → treated as an ERROR, never as a change. The stored hash
is left untouched so a source that recovers to its previous output still
suppresses.
Output is compared as EXACT BYTES — no timestamp stripping or whitespace
normalization. Monitor scripts should emit stable output (sort results,
omit "generated at" lines) or every tick will look like a change.
State lives in two places, both durable across scheduler restarts:
* ``job["monitor_state"]`` in jobs.json — ``last_output_hash`` +
``last_changed_at`` (additive JSON fields, no migration needed);
* ``OUTPUT_DIR/<job_id>/monitor_last_output.txt`` — the previous output
text, kept only so the next change can render a diff.
Inspired by: ChatGPT Work monitor tasks (idea-level, docs-only);
enabler: #80774.
"""
from __future__ import annotations
import difflib
import hashlib
import logging
from dataclasses import dataclass
from typing import Optional
logger = logging.getLogger(__name__)
# Cap for the unified diff injected into the prompt.
MAX_DIFF_CHARS = 4000
# Cap for the new-output block injected into the prompt (mirrors the 8k
# context_from truncation in cron/scheduler.py).
MAX_OUTPUT_CHARS = 8000
# Bounded GET limits for monitor_url sources.
URL_TIMEOUT_SECONDS = 30
MAX_URL_BYTES = 262_144 # 256 KiB
_SNAPSHOT_FILENAME = "monitor_last_output.txt"
@dataclass
class MonitorOutcome:
"""Result of one monitor-source evaluation."""
ok: bool
changed: bool = False
first_run: bool = False
context_block: Optional[str] = None
error: Optional[str] = None
def hash_monitor_output(output: str) -> str:
"""Hash the monitor output as exact UTF-8 bytes (no normalization)."""
return hashlib.sha256(output.encode("utf-8", errors="replace")).hexdigest()
def build_monitor_diff(old: str, new: str) -> str:
"""Unified diff of old vs new monitor output, capped at MAX_DIFF_CHARS."""
diff = "\n".join(
difflib.unified_diff(
old.splitlines(),
new.splitlines(),
fromfile="previous",
tofile="current",
lineterm="",
)
)
if len(diff) > MAX_DIFF_CHARS:
diff = diff[:MAX_DIFF_CHARS] + "\n... [diff truncated]"
return diff
def _snapshot_path(job_id: str):
from cron.jobs import _job_output_dir
return _job_output_dir(job_id) / _SNAPSHOT_FILENAME
def _read_last_output(job_id: str) -> str:
try:
path = _snapshot_path(job_id)
if path.exists():
return path.read_text(encoding="utf-8")
except Exception as exc:
logger.warning("Monitor: failed to read last output for %r: %s", job_id, exc)
return ""
def _write_last_output(job_id: str, output: str) -> None:
try:
path = _snapshot_path(job_id)
from cron.jobs import _ensure_cron_dir
_ensure_cron_dir(path.parent)
path.write_text(output, encoding="utf-8")
except Exception as exc:
logger.warning("Monitor: failed to persist last output for %r: %s", job_id, exc)
def _fetch_monitor_url(url: str) -> tuple[bool, str]:
"""Bounded GET of a monitor URL. Returns (ok, body-or-error)."""
import urllib.request
if not str(url).lower().startswith(("http://", "https://")):
return False, f"monitor_url must be http(s): {url!r}"
try:
req = urllib.request.Request(url, headers={"User-Agent": "hermes-cron-monitor"})
with urllib.request.urlopen(req, timeout=URL_TIMEOUT_SECONDS) as resp: # nosec B310 — scheme checked above
body = resp.read(MAX_URL_BYTES + 1)
if len(body) > MAX_URL_BYTES:
body = body[:MAX_URL_BYTES]
return True, body.decode("utf-8", errors="replace")
except Exception as exc:
return False, f"monitor_url fetch failed: {exc}"
def _run_monitor_source(job: dict) -> tuple[bool, str]:
"""Run the job's monitor source (script or URL). Returns (ok, output)."""
monitor_script = (job.get("monitor_script") or "").strip()
if monitor_script:
# Same containment + interpreter rules as the existing `script` field.
from cron.scheduler import _run_job_script
workdir = (job.get("workdir") or "").strip() or None
return _run_job_script(monitor_script, workdir=workdir)
monitor_url = (job.get("monitor_url") or "").strip()
if monitor_url:
return _fetch_monitor_url(monitor_url)
return False, "monitor job has neither monitor_script nor monitor_url"
def job_has_monitor(job: dict) -> bool:
return bool((job.get("monitor_script") or "").strip() or (job.get("monitor_url") or "").strip())
def check_monitor(job: dict) -> MonitorOutcome:
"""Run the monitor source and decide whether the agent should run.
On change (or first run) the new hash + snapshot are persisted BEFORE
the agent runs — detection time is the state boundary, so a failed
agent run doesn't re-alert on the same content forever.
On failure nothing is persisted.
"""
job_id = str(job.get("id") or "")
ok, output = _run_monitor_source(job)
if not ok:
return MonitorOutcome(ok=False, error=output)
new_hash = hash_monitor_output(output)
raw_state = job.get("monitor_state")
state = raw_state if isinstance(raw_state, dict) else {}
last_hash = state.get("last_output_hash")
if last_hash is not None and new_hash == last_hash:
return MonitorOutcome(ok=True, changed=False)
first_run = last_hash is None
old_output = "" if first_run else _read_last_output(job_id)
shown_output = output
if len(shown_output) > MAX_OUTPUT_CHARS:
shown_output = shown_output[:MAX_OUTPUT_CHARS] + "\n... [output truncated]"
if first_run:
context_block = (
"## Monitor Baseline (first run)\n\n"
"This is the first observation of the monitored source — there is "
"no previous output to diff against.\n\n"
f"### Current output\n\n```\n{shown_output}\n```"
)
else:
diff = build_monitor_diff(old_output, output)
context_block = (
"## MONITOR CHANGE DETECTED\n\n"
"The monitored source's output changed since the last run.\n\n"
f"### Diff (previous → current)\n\n```diff\n{diff}\n```\n\n"
f"### Current output\n\n```\n{shown_output}\n```"
)
_persist_monitor_state(job_id, new_hash, output)
return MonitorOutcome(
ok=True, changed=True, first_run=first_run, context_block=context_block
)
def _persist_monitor_state(job_id: str, new_hash: str, output: str) -> None:
from cron.jobs import _hermes_now, update_job
_write_last_output(job_id, output)
try:
update_job(
job_id,
{
"monitor_state": {
"last_output_hash": new_hash,
"last_changed_at": _hermes_now().isoformat(),
}
},
)
except Exception as exc:
logger.warning("Monitor: failed to persist state for %r: %s", job_id, exc)
+200
View File
@@ -0,0 +1,200 @@
"""Per-job durable notepad for cron jobs.
A tiny KV scratchpad each cron job can use to carry state across scheduled
wake-ups (cursors, watermarks, watchlists). Stored in its own profile-local
SQLite file next to the executions ledger, following the same
connection/pragma pattern as ``cron/executions.py``.
Size caps (documented contract):
- ``MAX_VALUE_BYTES`` (16 KB): per-key value cap, measured in UTF-8 bytes.
- ``MAX_JOB_TOTAL_BYTES`` (64 KB): per-job cap over the sum of key+value
bytes. Oversized writes raise ``ValueError`` and leave the store
untouched — the notepad is prompt-injected each run, so unbounded growth
would bloat every wake-up's prompt.
Write path is the CLI (``hermes cron notepad <job_id> set <key> <value>``),
which the running agent invokes via its terminal tool; no model tool is
added.
Inspired by: Amp (Sourcegraph) cron notepad (idea-level, proprietary — zero
code).
"""
from __future__ import annotations
import sqlite3
import threading
from contextlib import contextmanager
from pathlib import Path
from typing import Any, Dict, Iterator, List, Optional
from hermes_constants import get_hermes_home
from hermes_time import now as _hermes_now
# Optional test override. Production resolves the path at transaction time so
# multiplexed profile ticks (set_hermes_home_override) cannot leak one
# profile's notepad rows into the import-time home — and remove_job's
# clear_notepad cannot wipe the wrong profile's DB (#86519). Same pattern as
# cron/executions.py.
NOTEPAD_FILE: Optional[Path] = None
MAX_VALUE_BYTES = 16 * 1024
MAX_KEY_CHARS = 128
MAX_JOB_TOTAL_BYTES = 64 * 1024
_lock = threading.RLock()
def _current_notepad_file() -> Path:
return NOTEPAD_FILE or (get_hermes_home().resolve() / "cron" / "notepad.db")
def _connect() -> sqlite3.Connection:
from cron.jobs import _ensure_cron_dir
path = _current_notepad_file()
_ensure_cron_dir(path.parent)
return sqlite3.connect(path, timeout=5)
def _initialize_schema(conn: sqlite3.Connection) -> None:
from hermes_state import apply_wal_with_fallback
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA busy_timeout=5000")
apply_wal_with_fallback(conn, db_label="cron/notepad.db")
conn.execute(
"""CREATE TABLE IF NOT EXISTS cron_notepad (
job_id TEXT NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (job_id, key)
)"""
)
@contextmanager
def _transaction() -> Iterator[sqlite3.Connection]:
"""Open a connection, commit/rollback on exit, always close.
Mirrors ``cron.executions._transaction``: schema init runs inside the
``try`` so a PRAGMA/DDL failure still closes the connection instead of
leaking it.
"""
with _lock:
conn = _connect()
try:
_initialize_schema(conn)
with conn:
yield conn
finally:
conn.close()
def _validate(job_id: str, key: str, value: str) -> None:
if not str(job_id):
raise ValueError("job_id must be non-empty")
if not key:
raise ValueError("key must be non-empty")
if len(key) > MAX_KEY_CHARS:
raise ValueError(f"key too long (max {MAX_KEY_CHARS} characters)")
if len(value.encode("utf-8")) > MAX_VALUE_BYTES:
raise ValueError(
f"value too large (max {MAX_VALUE_BYTES} bytes per key)"
)
def set_note(job_id: str, key: str, value: str) -> Dict[str, Any]:
"""Upsert one key. Raises ValueError when a size cap would be exceeded."""
job_id, key, value = str(job_id), str(key), str(value)
_validate(job_id, key, value)
now = _hermes_now().isoformat()
with _transaction() as conn:
row = conn.execute(
"""SELECT COALESCE(SUM(LENGTH(CAST(key AS BLOB))
+ LENGTH(CAST(value AS BLOB))), 0)
FROM cron_notepad WHERE job_id=? AND key<>?""",
(job_id, key),
).fetchone()
other_bytes = int(row[0])
entry_bytes = len(key.encode("utf-8")) + len(value.encode("utf-8"))
if other_bytes + entry_bytes > MAX_JOB_TOTAL_BYTES:
raise ValueError(
f"notepad full: job '{job_id}' would exceed "
f"{MAX_JOB_TOTAL_BYTES} bytes total; delete unused keys first"
)
conn.execute(
"""INSERT INTO cron_notepad (job_id, key, value, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(job_id, key)
DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at""",
(job_id, key, value, now),
)
return {"job_id": job_id, "key": key, "value": value, "updated_at": now}
def get_note(job_id: str, key: str) -> Optional[str]:
with _transaction() as conn:
row = conn.execute(
"SELECT value FROM cron_notepad WHERE job_id=? AND key=?",
(str(job_id), str(key)),
).fetchone()
return None if row is None else row["value"]
def delete_note(job_id: str, key: str) -> bool:
with _transaction() as conn:
cur = conn.execute(
"DELETE FROM cron_notepad WHERE job_id=? AND key=?",
(str(job_id), str(key)),
)
return cur.rowcount > 0
def list_notes(job_id: str) -> List[Dict[str, Any]]:
"""All entries for one job, sorted by key."""
with _transaction() as conn:
rows = conn.execute(
"SELECT job_id, key, value, updated_at FROM cron_notepad "
"WHERE job_id=? ORDER BY key",
(str(job_id),),
).fetchall()
return [dict(row) for row in rows]
def clear_notepad(job_id: str) -> int:
"""Delete every key for one job (e.g. on job removal). Returns row count.
Called from ``cron.jobs.remove_job`` so deleted jobs don't orphan their
rows. No-ops without creating the DB when no notepad file exists yet.
"""
if not _current_notepad_file().exists():
return 0
with _transaction() as conn:
cur = conn.execute(
"DELETE FROM cron_notepad WHERE job_id=?", (str(job_id),)
)
return cur.rowcount
def render_notepad_section(job_id: str) -> str:
"""Render a job's notepad as a prompt section, or '' when empty/unavailable.
Empty notepad MUST return the empty string so jobs that never use the
feature get a byte-identical prompt (prompt-cache + drift safety).
"""
try:
notes = list_notes(job_id)
except Exception:
return ""
if not notes:
return ""
lines = [f"- {note['key']}: {note['value']}" for note in notes]
return (
"## Job notepad (persistent across runs)\n"
"This durable scratchpad survives between scheduled runs of this "
"job. Update it via the CLI, e.g.:\n"
f"`hermes cron notepad {job_id} set <key> <value>` "
f"(also: get/delete/list; `hermes cron notepad {job_id} delete "
"<key>` removes an entry).\n\n" + "\n".join(lines) + "\n\n"
)
+9006
View File
File diff suppressed because it is too large Load Diff
+865
View File
@@ -0,0 +1,865 @@
"""CronScheduler provider interface (Axis B — the trigger).
⚠️ EXPERIMENTAL — this interface is validated by exactly ONE consumer (the
built-in) until an external provider (Chronos, Phase 4) shakes it out. Until
then the module path, method signatures, and start() kwargs MAY change without
a deprecation cycle. Once a second provider validates the shape it becomes
stable. Any growth MUST be additive (new optional method with a default), never
a changed signature on start() or a new abstractmethod.
A CronScheduler decides *when* a due job fires. It does NOT decide what firing
means: execution + delivery stay in cron.scheduler.run_job / _deliver_result,
shared by all providers. Providers must never reimplement agent construction or
delivery.
The built-in InProcessCronScheduler runs the historical 60s daemon-thread
ticker. Alternative providers (e.g. Chronos, a NAS-mediated managed-cron
provider for scale-to-zero deployments) live under plugins/cron_providers/<name>/ and are
selected via the `cron.provider` config key (empty = built-in).
"""
from __future__ import annotations
import inspect
import threading
from abc import ABC, abstractmethod
from pathlib import Path
from typing import Any
# Cap for the exponential tick backoff applied while consecutive ticks fail
# with fd exhaustion (EMFILE/ENFILE, #87644). Base is the tick interval
# (60s by default); each consecutive EMFILE failure doubles the wait, capped
# here so a still-alive-but-exhausted gateway never sleeps longer than this
# between recovery attempts.
_EMFILE_BACKOFF_MAX_SECONDS = 15 * 60 # 15 minutes
def _backoff_wait_seconds(interval: float, consecutive_failures: int) -> float:
"""Exponential tick backoff shared by both ticker loops (#87644).
Returns the plain ``interval`` while healthy; doubles per consecutive
fd-exhaustion failure, capped at ``_EMFILE_BACKOFF_MAX_SECONDS``.
"""
if consecutive_failures <= 0:
return interval
return min(
interval * (2 ** (consecutive_failures - 1)),
_EMFILE_BACKOFF_MAX_SECONDS,
)
def _note_tick_failure(exc: BaseException, consecutive_failures: int) -> int:
"""Classify one failed tick and return the updated failure counter.
Shared by both ticker loops (#87644): on fd exhaustion, attempt
reclamation (gc.collect + raise the soft nofile limit) so the NEXT tick
can succeed, and bump the counter so ``_backoff_wait_seconds`` backs off
exponentially while the process has no chance of making progress. Any
other failure resets the counter — backoff is reserved for the
self-inflicted EMFILE storm, not transient errors.
"""
from cron.scheduler import _is_fd_exhaustion, _reclaim_fds_best_effort
if _is_fd_exhaustion(exc):
_reclaim_fds_best_effort()
return consecutive_failures + 1
return 0
def _existing_profile_homes(profile_homes: list) -> list:
"""Drop profile homes whose directory no longer exists on disk.
The multiplex ticker's ``profile_homes`` is a snapshot taken at startup
(``web_server.py`` calls ``profiles_to_serve(multiplex=True)`` once, and
the gateway multiplex path does the same). If a profile is deleted while
the ticker runs — via ``hermes profile delete``, the desktop's DELETE
``/api/profiles/<name>`` route, or any other path that removes the home
directory — that stale entry stays in the list.
Ticking or heartbeating a deleted home recreates its ``cron/`` workspace
(``record_ticker_heartbeat`` -> ``ensure_dirs`` -> ``mkdir(parents=True)``)
on every 60s cycle, so the "deleted" profile silently comes back on disk
and in ``hermes profile list`` (#47368). Filtering on directory existence
leaves a deleted profile's home untouched, which is the correct invariant:
a home that does not exist cannot hold jobs to fire.
"""
live = []
for entry in profile_homes:
home = entry[1] if isinstance(entry, tuple) else entry
if Path(home).is_dir():
live.append(entry)
return live
class CronScheduler(ABC):
"""Axis-B trigger provider. Decides WHEN a due cron job fires.
Required surface is intentionally minimal: ``name`` + ``start``. ``stop``
and ``is_available`` carry safe defaults. The three Phase-4 hooks
(``on_jobs_changed`` / ``fire_due`` / ``reconcile``) are added later as
NON-abstract methods so the built-in keeps satisfying the ABC without
overriding them — see ``test_abc_growth_stays_additive``.
"""
@property
@abstractmethod
def name(self) -> str:
"""Short identifier, e.g. 'builtin', 'chronos'."""
def is_available(self) -> bool:
"""Whether this provider can run in the current environment.
MUST NOT make network calls. The built-in is always available; an
external provider checks for configured endpoint/credentials. When a
named provider returns False, the resolver falls back to the built-in.
"""
return True
@abstractmethod
def start(
self,
stop_event: threading.Event,
*,
adapters: Any = None,
loop: Any = None,
interval: int = 60,
) -> None:
"""Begin firing due jobs.
For the built-in this BLOCKS in the 60s loop until stop_event is set
(it is run inside a daemon thread by the caller, exactly as today).
An external provider may register a schedule/webhook and return
immediately; in that case it must still honor stop_event for teardown.
"""
def stop(self) -> None:
"""Optional eager teardown hook. Default no-op; setting the stop_event
is the primary stop signal. Override for providers holding external
resources (queue consumers, HTTP servers)."""
return None
# --- Optional hooks for external providers (added Phase 4). --------------
# All default-safe so the built-in inherits working behavior without
# overriding. Keep these NON-abstract — see test_abc_growth_stays_additive.
def on_jobs_changed(self) -> None:
"""Called after a successful store mutation (create/update/remove/
pause/resume). External providers reconcile their registry here (e.g.
Chronos re-provisions/cancels the affected one-shot via NAS).
Built-in: no-op (it re-reads jobs.json on every tick)."""
return None
def register_job(self, job: dict[str, Any]) -> None:
"""Register the first external trigger for one newly persisted job.
The built-in provider reads the local store on every tick, so its
default is a no-op. External providers override this when creating a
job requires a remote registration before callers can honestly report
that the job is scheduled.
"""
return None
def recover_interrupted(self) -> int:
"""Run profile-local attempt recovery for every provider lifecycle."""
from cron.executions import recover_interrupted_executions
return recover_interrupted_executions()
@property
def supports_force_fire(self) -> bool:
"""Whether ``fire_due`` accepts the additive ``force`` keyword.
Signature detection keeps providers written before ``force`` was added
source-compatible. Providers accepting ``**kwargs`` are compatible.
"""
return provider_supports_force_fire(self)
def fire_due(
self,
job_id: str,
*,
adapters: Any = None,
loop: Any = None,
force: bool = False,
) -> bool:
"""Run a single job NOW via the shared orchestrator. Called by the
inbound fire webhook when an external scheduler signals a job is due.
The default claims the job with a store-level compare-and-set
(multi-machine at-most-once), then runs it via the shared
``run_one_job`` body. Built-in never calls this (it has its own tick
loop); an external provider routes its inbound fire here.
Returns True if THIS caller claimed and processed the attempt, even if
the job itself failed. Returns False only if the claim was lost
(another machine/retry won it) or the job no longer exists.
"""
claimed_job = self.claim_fire(job_id, force=force)
if claimed_job is None:
return False
return self.fire_claimed(claimed_job, adapters=adapters, loop=loop)
def claim_fire(self, job_id: str, *, force: bool = False) -> dict | None:
"""Durably claim one fire and create its audit attempt before dispatch.
Webhook transports call this synchronously before acknowledging the
external scheduler, then pass the exact owner-bearing snapshot to
``fire_claimed`` in tracked background work.
"""
from cron.executions import create_execution, finish_execution
from cron.jobs import claim_job_for_fire
execution = create_execution(job_id, source=self.name)
claim_kwargs = {"return_job": True}
if force:
claim_kwargs["force"] = True
try:
claimed_job = claim_job_for_fire(job_id, **claim_kwargs)
except BaseException as exc:
finish_execution(
execution["id"],
success=False,
error=f"Fire claim failed before dispatch: {type(exc).__name__}: {exc}",
)
raise
if not isinstance(claimed_job, dict):
finish_execution(
execution["id"],
success=False,
error="Fire claim was not acquired",
)
return None
claimed_job["execution_id"] = execution["id"]
return claimed_job
def fire_claimed(
self,
claimed_job: dict,
*,
adapters: Any = None,
loop: Any = None,
cancel_event: Any = None,
) -> bool:
"""Run an exact snapshot returned by ``claim_fire``.
``cancel_event``: optional transport-owned ``threading.Event`` (or
compatible) that lets the caller stop this execution cooperatively
— e.g. the dashboard lifespan drain signalling pending webhook
fires before the event loop shuts down.
"""
from cron.scheduler import run_one_job
run_one_job(
claimed_job,
adapters=adapters,
loop=loop,
cancel_event=cancel_event,
)
return True
def reconcile(self) -> None:
"""Converge the external registry toward jobs.json (the desired state):
arm missing one-shots, cancel orphaned ones, re-arm changed times.
Built-in: no-op."""
return None
def provider_supports_force_fire(provider: Any) -> bool:
"""Return whether a provider can safely receive ``fire_due(force=...)``."""
try:
parameters = inspect.signature(provider.fire_due).parameters.values()
except (TypeError, ValueError):
return False
return any(
parameter.kind is inspect.Parameter.VAR_KEYWORD
or (
parameter.name == "force"
and parameter.kind
in (inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY)
)
for parameter in parameters
)
def provider_supports_split_fire(provider: Any) -> bool:
"""Return whether a provider implements the two-phase fire contract.
The webhook admission path uses ``claim_fire`` + ``fire_claimed`` so the
202 response is backed by a durable, owner-fenced claim. A legacy
third-party provider that overrides the documented single-phase
``fire_due`` hook (custom claim/re-arm/telemetry behavior) but inherits
the base ``claim_fire`` must keep being driven through its own
``fire_due`` — silently routing around its override would drop that
behavior. Providers that customize ``claim_fire`` itself are already
split-aware and keep the two-phase path.
"""
cls = type(provider)
fire_due_impl = getattr(cls, "fire_due", None)
claim_fire_impl = getattr(cls, "claim_fire", None)
fire_claimed_impl = getattr(cls, "fire_claimed", None)
if claim_fire_impl is not None and claim_fire_impl is not CronScheduler.claim_fire:
return True
# Overriding the second phase is also proof of split-awareness (the
# provider composes with the inherited claim path) — e.g. Chronos keeps
# its re-arm logic in ``fire_claimed`` only.
if fire_claimed_impl is not None and fire_claimed_impl is not CronScheduler.fire_claimed:
return True
if fire_due_impl is None or fire_due_impl is CronScheduler.fire_due:
return True
return False
def provider_supports_fire_cancel(provider: Any) -> bool:
"""Return whether ``fire_claimed`` accepts a ``cancel_event`` kwarg."""
try:
parameters = inspect.signature(provider.fire_claimed).parameters.values()
except (TypeError, ValueError):
return False
return any(
parameter.kind is inspect.Parameter.VAR_KEYWORD
or (
parameter.name == "cancel_event"
and parameter.kind
in (inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY)
)
for parameter in parameters
)
DEFAULT_MISFIRE_GRACE_MINUTES = 10
def _misfire_grace_minutes() -> float:
"""Resolve the misfire catch-up grace window from config.
``cron.misfire_grace_minutes`` (number, default
``DEFAULT_MISFIRE_GRACE_MINUTES``). A non-positive value disables the
catch-up sweep entirely.
"""
try:
from hermes_cli.config import cfg_get, load_config
return float(
cfg_get(
load_config(),
"cron",
"misfire_grace_minutes",
default=DEFAULT_MISFIRE_GRACE_MINUTES,
)
)
except Exception:
return float(DEFAULT_MISFIRE_GRACE_MINUTES)
def fire_overdue_jobs(
provider: "CronScheduler",
*,
adapters: Any = None,
loop: Any = None,
now: Any = None,
) -> int:
"""Fire jobs whose scheduled time passed without an external fire arriving.
The misfire catch-up half of the hosted fire path. External providers
(Chronos) deliver scheduled fires over HTTP to this process's api_server
adapter; when that hop is down at fire time (gateway restart window,
api_server not bound, scheduler retry budget exhausted), the job's
``next_run_at`` stays parked in the past and — because external providers
have no local tick loop — nothing ever runs it. The day is silently lost
even though the gateway may be healthy again minutes later.
Called from the gateway housekeeping loop. Deliberately:
- **No-op for the built-in provider.** Its tick loop already picks up
past-due jobs via ``get_due_jobs`` — local scheduling self-heals.
- **Routes through the provider's own two-phase fire path** — a
synchronous ``claim_fire`` (store CAS, so a late external retry
landing concurrently is de-duplicated) and then ``fire_claimed`` in
a daemon thread, mirroring the webhook admission pattern. The
housekeeping loop that calls this must never block for the length
of an agent run. Provider-specific re-arm logic (Chronos NAS
one-shots) runs exactly as for a normal fire.
- **Waits out a grace window** (``cron.misfire_grace_minutes``, default
10, non-positive disables) so the external scheduler's own retry
backoff gets first right to deliver — catch-up is the backstop, not
a race.
- **Operates on the process-global cron store only** — same profile
scoping as the external provider's reconcile.
Returns the number of jobs this sweep claimed and dispatched.
"""
import logging
import threading
from datetime import datetime
logger = logging.getLogger("cron.scheduler_provider")
if isinstance(provider, InProcessCronScheduler):
return 0
grace_minutes = _misfire_grace_minutes()
if grace_minutes <= 0:
return 0
from cron.jobs import _ensure_aware, _hermes_now, is_job_runnable, load_jobs
if now is None:
now = _hermes_now()
fired = 0
for job in load_jobs():
if not is_job_runnable(job):
continue
next_run_at = job.get("next_run_at")
if not next_run_at:
continue
try:
due_dt = _ensure_aware(datetime.fromisoformat(next_run_at))
except (ValueError, TypeError):
continue
overdue_seconds = (now - due_dt).total_seconds()
if overdue_seconds < grace_minutes * 60:
continue
job_id = str(job.get("id") or "")
# One-shot jobs share the module-wide policy: more than
# ONESHOT_GRACE_SECONDS past their run time means "will never fire"
# (create/update/resume/recovery and, since #89571, the due-scan all
# enforce it). The misfire backstop must not resurrect them hours
# late after downtime — that's #93526.
schedule = job.get("schedule") or {}
if str(schedule.get("kind") or "") == "once":
from cron.jobs import ONESHOT_GRACE_SECONDS
if overdue_seconds > ONESHOT_GRACE_SECONDS:
logger.warning(
"Misfire catch-up: one-shot job %s (%s) was due %s "
"(%.0f min overdue) — outside the %ss one-shot grace "
"window, not firing.",
job_id,
job.get("name") or "unnamed",
next_run_at,
overdue_seconds / 60,
ONESHOT_GRACE_SECONDS,
)
continue
logger.warning(
"Misfire catch-up: job %s (%s) was due %s (%.0f min overdue) and "
"no external fire arrived — firing locally.",
job_id,
job.get("name") or "unnamed",
next_run_at,
overdue_seconds / 60,
)
try:
# Two-phase, webhook-style: claim synchronously (fast store
# CAS — losing means an external retry beat us, which is
# fine), then run the job off-thread so the caller's loop is
# never blocked for the length of an agent run.
claimed = provider.claim_fire(job_id)
if claimed is None:
continue
threading.Thread(
target=provider.fire_claimed,
args=(claimed,),
kwargs={"adapters": adapters, "loop": loop},
daemon=True,
name=f"cron-misfire-{job_id[:12]}",
).start()
fired += 1
except Exception as exc:
logger.warning(
"Misfire catch-up failed for job %s: %s: %s",
job_id, type(exc).__name__, exc,
)
return fired
def resolve_cron_scheduler() -> "CronScheduler":
"""Return the active cron scheduler provider.
Reads ``cron.provider`` from config. Empty/absent → built-in. A named
provider that is missing, fails to load, or reports ``is_available() ==
False`` falls back to the built-in with a warning — cron must never be left
without a trigger.
"""
import logging
logger = logging.getLogger("cron.scheduler_provider")
name = ""
try:
from hermes_cli.config import cfg_get, load_config
name = (cfg_get(load_config(), "cron", "provider", default="") or "").strip()
except Exception:
pass
if not name or name in ("builtin", "in-process", "inprocess"):
return InProcessCronScheduler()
try:
from plugins.cron_providers import load_cron_scheduler
provider = load_cron_scheduler(name)
if provider is None:
logger.warning("cron.provider '%s' not found; using built-in ticker", name)
return InProcessCronScheduler()
if not provider.is_available():
logger.warning("cron.provider '%s' not available; using built-in ticker", name)
return InProcessCronScheduler()
logger.info("Using cron scheduler provider: %s", provider.name)
return provider
except Exception as e:
logger.warning(
"Failed to load cron.provider '%s' (%s); using built-in ticker", name, e
)
return InProcessCronScheduler()
def scheduler_for_profile_mode(
provider: "CronScheduler", *, multiplex_profiles: bool
) -> "CronScheduler":
"""Return a scheduler that can safely serve the gateway's profile mode.
External providers currently own one unscoped remote registry/client and
therefore cannot safely reconcile several profile stores from one process.
Fail closed to the built-in multiplex ticker until the provider API carries
explicit profile identity through lifecycle and webhook calls.
"""
if not multiplex_profiles or isinstance(provider, InProcessCronScheduler):
return provider
import logging
logging.getLogger("cron.scheduler_provider").warning(
"cron.provider '%s' does not support multiplex_profiles; using built-in ticker",
provider.name,
)
return InProcessCronScheduler()
class InProcessCronScheduler(CronScheduler):
"""Default provider: the historical in-process 60s ticker.
``start()`` blocks in the tick loop until ``stop_event`` is set, identical
to the pre-refactor ``_start_cron_ticker`` core loop. The caller runs it in
a daemon thread. ``can_dispatch`` is an optional synchronous gate supplied
by GatewayRunner during external drain; skipped ticks leave due jobs intact
for the next allowed tick.
"""
@property
def name(self) -> str:
return "builtin"
def start(
self,
stop_event,
*,
adapters=None,
loop=None,
interval=60,
can_dispatch=None,
profile_homes=None,
profile_adapters=None,
default_profile=None,
profile_gate=None,
):
import logging
from cron.scheduler import CronTickYielded
from cron.scheduler import tick as cron_tick
from cron.jobs import (
clear_ticker_error,
record_ticker_error,
record_ticker_heartbeat,
)
logger = logging.getLogger("cron.scheduler_provider")
logger.info("In-process cron scheduler started (interval=%ds)", interval)
# ── Multiplex profiles ────────────────────────────────────────────
# When profile_homes is set (multiplex_profiles on), tick EACH profile's
# cron store on every tick cycle so secondary-profile jobs actually fire
# instead of languishing in a store no ticker owns (#69377). Without this,
# only the process-global HERMES_HOME (the default profile) is ticked.
# Heartbeats and recovery are also scoped per profile so `hermes cron
# status` reflects liveness for every profile independently.
if profile_homes:
self._start_multiplex(
stop_event,
profile_homes=profile_homes,
adapters=adapters,
loop=loop,
interval=interval,
can_dispatch=can_dispatch,
profile_adapters=profile_adapters,
default_profile=default_profile,
profile_gate=profile_gate,
)
return
# ── Single-profile (legacy) path ──────────────────────────────────
recovered = self.recover_interrupted()
if recovered:
logger.warning(
"Marked %d interrupted cron execution(s) unknown after restart",
recovered,
)
# Heartbeat once before the first sleep so `hermes cron status` sees a
# live ticker immediately after startup, not only after the first tick.
record_ticker_heartbeat()
# Exponential backoff for consecutive tick failures — most importantly
# fd exhaustion (EMFILE/ENFILE, #87644). While FDs stay exhausted the
# ticker must NOT hammer the store every 60s; once they free (leak
# fixed, reclamation ran) the next tick succeeds and the backoff
# resets, so the scheduler self-heals without a gateway restart.
consecutive_failures = 0
while not stop_event.is_set():
ok = False
try:
if can_dispatch is not None and not can_dispatch():
logger.debug("Cron dispatch paused while gateway drains existing work")
else:
cron_tick(
verbose=False,
adapters=adapters,
loop=loop,
sync=False,
can_dispatch=can_dispatch,
)
ok = True
except BaseException as e:
# Catch BaseException (not just Exception) so a SystemExit from
# a misbehaving provider SDK / agent retry path does not kill
# the ticker thread silently (#32612). KeyboardInterrupt is
# intentionally caught here too — gateway shutdown is driven by
# stop_event (set by the main thread's signal handler), not by
# an exception in this daemon thread, so swallowing it and
# re-checking stop_event keeps shutdown clean.
if isinstance(e, CronTickYielded):
# Expected while this process is stale and a fresh gateway
# owns the runtime lock: not an error to debug, but it IS
# recorded below so status shows why ticks aren't firing
# from here. tick() already logged it once per episode.
logger.info("Cron tick yielded: %s", e)
else:
logger.error("Cron tick error: %s", e, exc_info=True)
# Persist the failure reason next to the heartbeat markers so
# `hermes cron status`/`list` (separate processes) can show
# WHY ticks fail, not just that the success marker is stale —
# e.g. a root-rewritten jobs.json locking out the ticker's
# uid went unnoticed for ~14h with the reason buried in the
# gateway log (#68483).
record_ticker_error(f"{type(e).__name__}: {e}")
# EMFILE: reclaim fds + back off exponentially so the
# exhausted process stops hammering the store while it has no
# chance of making progress (#87644).
consecutive_failures = _note_tick_failure(e, consecutive_failures)
# Record liveness every iteration; bump the success marker only on a
# clean tick, so status can tell "alive but failing every tick" from
# "actually firing jobs" (#32612, #32895).
record_ticker_heartbeat(success=ok)
if ok:
clear_ticker_error()
consecutive_failures = 0
stop_event.wait(_backoff_wait_seconds(interval, consecutive_failures))
def _start_multiplex(
self,
stop_event,
*,
profile_homes,
adapters=None,
loop=None,
interval=60,
can_dispatch=None,
profile_adapters=None,
default_profile=None,
profile_gate=None,
):
"""Tick every served profile's cron store when multiplex_profiles is on.
Each profile uses ``set_hermes_home_override()`` + ``use_cron_store()``
to scope its tick, heartbeat, recovery, lock file, config/.env, and
agent execution to that profile's home — mirroring how
``_profile_runtime_scope`` scopes the multiplexed inbound path and
``web_server.py`` scopes per-profile cron API calls.
``profile_gate(name, home) -> bool``, when given, is consulted every
cycle; a profile it rejects is neither ticked nor heartbeated that
cycle (the desktop ticker uses it to stand down for profiles whose
own gateway is running, #100489).
"""
import logging
from cron.scheduler import tick as cron_tick
from cron.scheduler import (
CronTickYielded,
SharedRouteAdapters,
_is_fd_exhaustion,
_primary_profile_routes_for_current_home,
)
from cron.jobs import (
clear_ticker_error,
record_ticker_error,
record_ticker_heartbeat,
use_cron_store,
)
from hermes_constants import set_hermes_home_override, reset_hermes_home_override
logger = logging.getLogger("cron.scheduler_provider")
logger.info(
"Multiplex cron scheduler started for %d profile(s): %s",
len(profile_homes),
[p[0] if isinstance(p, tuple) else p for p in profile_homes],
)
# Recovery + initial heartbeat for every profile.
# A profile may have been deleted since this snapshot was taken;
# never recreate a deleted home's cron workspace via the heartbeat
# below (#47368).
# One profile's broken store (corrupt executions.db, unreadable
# cron dir) must not abort startup for every other profile (#74878).
for entry in _existing_profile_homes(profile_homes):
home = entry[1] if isinstance(entry, tuple) else entry
home_token = set_hermes_home_override(str(home))
try:
with use_cron_store(home):
recovered = self.recover_interrupted()
if recovered:
logger.warning(
"Marked %d interrupted cron execution(s) for profile at %s",
recovered,
home,
)
record_ticker_heartbeat()
except BaseException as e:
logger.error(
"Cron startup recovery error for profile at %s: %s",
home,
e,
exc_info=True,
)
finally:
reset_hermes_home_override(home_token)
consecutive_failures = 0
while not stop_event.is_set():
ok = False
_tick_error = None
_profile_errors: dict[str, str] = {}
# Worst per-profile failure this cycle (fd exhaustion wins) so the
# #87644 backoff/reclaim is applied once per cycle, not per profile.
_cycle_exc: BaseException | None = None
cycle_homes = _existing_profile_homes(profile_homes)
if profile_gate is not None:
cycle_homes = [
entry
for entry in cycle_homes
if profile_gate(
entry[0] if isinstance(entry, tuple) else None,
entry[1] if isinstance(entry, tuple) else entry,
)
]
try:
if can_dispatch is not None and not can_dispatch():
logger.debug("Cron dispatch paused while gateway drains existing work")
else:
for entry in cycle_homes:
_pname = entry[0] if isinstance(entry, tuple) else None
home = entry[1] if isinstance(entry, tuple) else entry
home_token = set_hermes_home_override(str(home))
try:
with use_cron_store(home):
# Deliver each profile's cron via ITS OWN adapters.
# The shared `adapters` set belongs to the default
# profile only. A secondary profile uses its own map
# in profile_adapters[name], which is populated only
# once that profile's bot connects. A secondary must
# NEVER fall back to the default profile's `adapters`
# (that ships its cron output through the wrong bot),
# so before its adapter connects — map absent or empty
# — it simply does not deliver this tick.
if _pname is None or _pname == default_profile:
_tick_adapters = adapters
else:
_tick_adapters = (profile_adapters or {}).get(_pname) or {}
if not _tick_adapters and adapters:
# Credentialless satellite under
# gateway.profile_routes: no bot of its
# own, so its output may ride the
# PRIMARY adapter — but only for
# targets an exact enabled primary
# route maps to this profile
# (#101113). Unmatched targets still
# fail closed; this is not a default
# fallback.
_tick_adapters = SharedRouteAdapters(
adapters,
_primary_profile_routes_for_current_home(),
)
cron_tick(
verbose=False,
adapters=_tick_adapters,
loop=loop,
sync=False,
can_dispatch=can_dispatch,
)
except CronTickYielded as e:
# This profile is served stale and a fresh
# gateway owns its runtime lock: record the yield
# for THIS profile's status only, and keep
# ticking the remaining profiles — one profile's
# fresh gateway must not cancel another profile's
# only ticker in the same cycle.
logger.info("Cron tick yielded for profile at %s: %s", home, e)
_profile_errors[str(home)] = f"{type(e).__name__}: {e}"
except BaseException as e:
# Any other failure is THIS profile's failure
# (#74878): record it against this profile's
# status and keep ticking the remaining profiles.
# BaseException for the same reason as the
# single-profile loop (#32612).
logger.error(
"Cron tick error for profile at %s: %s",
home,
e,
exc_info=True,
)
_profile_errors[str(home)] = f"{type(e).__name__}: {e}"
if _cycle_exc is None or _is_fd_exhaustion(e):
_cycle_exc = e
finally:
reset_hermes_home_override(home_token)
ok = not _profile_errors
if _cycle_exc is not None:
consecutive_failures = _note_tick_failure(_cycle_exc, consecutive_failures)
except BaseException as e:
logger.error("Cron tick error: %s", e, exc_info=True)
_tick_error = f"{type(e).__name__}: {e}"
# EMFILE: reclaim fds + exponential backoff (#87644).
consecutive_failures = _note_tick_failure(e, consecutive_failures)
# Record per-profile heartbeat after each tick cycle. Distinguish
# a COMPLETED cycle (``_tick_error`` unset) — where each profile's
# beat reflects its own outcome, so a yielding profile does not
# darken healthy siblings — from an aborted one (exception), where
# no profile completed and all beats are unsuccessful (#32612).
for entry in cycle_homes:
home = entry[1] if isinstance(entry, tuple) else entry
home_token = set_hermes_home_override(str(home))
try:
with use_cron_store(home):
_home_ok = (
_tick_error is None and str(home) not in _profile_errors
)
record_ticker_heartbeat(success=_home_ok)
# Surface the failure reason (or clear it) per profile
# so `hermes cron status` can show WHY ticks fail
# (#68483).
if _home_ok:
clear_ticker_error()
elif str(home) in _profile_errors:
record_ticker_error(_profile_errors[str(home)])
elif _tick_error:
record_ticker_error(_tick_error)
finally:
reset_hermes_home_override(home_token)
if ok:
consecutive_failures = 0
stop_event.wait(_backoff_wait_seconds(interval, consecutive_failures))
+1
View File
@@ -0,0 +1 @@
"""Scripts shipped with the cron subsystem (runnable via ``python3 -m cron.scripts.<name>``)."""
+226
View File
@@ -0,0 +1,226 @@
#!/usr/bin/env python3
"""Classify candidate items by urgency/importance and emit only the urgent ones.
The proactive-monitor pattern: a fetch step (a watcher script, an inbox dump, a
feed) produces a list of candidate items; this script scores each with a cheap
LLM and prints ONLY the items at or above a threshold. Below-threshold runs
print nothing, so a cron job wrapping this stays silent unless something
actually matters -- the classic urgency-monitor pattern (fetch -> classify
urgency -> surface only what's above the bar).
Design choices:
* Uses Hermes' auxiliary client with task="monitor", so the classifier model
is configured once in config.yaml (auxiliary.monitor.{provider,model}) and
can be a cheap fast model independent of the main chat model.
* Reads items as JSON (a list of objects) from stdin or --input-file.
* One LLM call scores the whole batch (cheap, single round-trip) and returns
structured scores; we filter locally.
* Empty result -> empty stdout -> the cron job's [SILENT]/empty-stdout path
suppresses delivery. No spam on quiet intervals.
Usage (standalone):
cat items.json | python classify_items.py --threshold 7 \
--criteria "Urgent if it needs a reply today or is from my manager/family"
Usage (wired to a watcher via cron, agent mode):
Ask the agent: "Every 10 minutes, run watch_http_json.py for my inbox feed,
pipe its JSON into classify_items.py with my urgency criteria, and deliver
whatever it prints. Stay silent if it prints nothing."
Item schema (flexible): each item is an object; the classifier sees the whole
object. A "title"/"subject"/"summary"/"text" field helps it judge. An "id"
field (any of id/guid/message_id/url) is echoed back so duplicates can be
deduped upstream.
"""
from __future__ import annotations
import argparse
import json
import sys
from typing import Any, Dict, List, Optional
def _eprint(*args: Any) -> None:
print(*args, file=sys.stderr)
def _load_items(input_file: Optional[str]) -> List[Dict[str, Any]]:
raw = ""
if input_file:
with open(input_file, encoding="utf-8") as f:
raw = f.read()
else:
raw = sys.stdin.read()
raw = raw.strip()
if not raw:
return []
try:
data = json.loads(raw)
except json.JSONDecodeError as e:
_eprint(f"classify_items: input is not valid JSON: {e}")
sys.exit(2)
if isinstance(data, dict):
# Allow {"items": [...]} or a single object.
if isinstance(data.get("items"), list):
return data["items"]
return [data]
if isinstance(data, list):
return [x for x in data if isinstance(x, dict)]
_eprint("classify_items: expected a JSON list or {items: [...]}")
sys.exit(2)
def _item_id(item: Dict[str, Any], index: int) -> str:
for key in ("id", "guid", "message_id", "url", "link"):
val = item.get(key)
if val:
return str(val)
return f"item-{index}"
_CLASSIFY_INSTRUCTIONS = (
"You are an urgency classifier for a proactive assistant. You will be given "
"a numbered list of items and the user's importance criteria. Score EACH "
"item from 0 (ignore entirely) to 10 (interrupt the user now). Return ONLY a "
"JSON array, one object per item, in the same order: "
'[{"index": <int>, "score": <int 0-10>, "reason": "<short>"}]. '
"No prose, no markdown fences. Be conservative: most items should score low. "
"Only score high when the item clearly meets the user's criteria."
)
def _build_prompt(items: List[Dict[str, Any]], criteria: str) -> str:
lines = [f"USER IMPORTANCE CRITERIA:\n{criteria}\n", "ITEMS:"]
for i, item in enumerate(items):
# Show a compact view; the model sees the salient fields.
view = {
k: item[k]
for k in ("title", "subject", "summary", "text", "body", "from", "sender", "url")
if k in item
}
if not view:
view = item # fall back to the whole object
lines.append(f"[{i}] {json.dumps(view, ensure_ascii=False)[:1200]}")
lines.append(
"\nReturn the JSON array of scores now (one object per item, same order)."
)
return "\n".join(lines)
def _parse_scores(content: str, n_items: int) -> Dict[int, Dict[str, Any]]:
text = (content or "").strip()
# Tolerate accidental markdown fences.
if text.startswith("```"):
text = text.strip("`")
if "\n" in text:
text = text.split("\n", 1)[1]
try:
arr = json.loads(text)
except json.JSONDecodeError:
# Last-ditch: find the first [...] block.
start = text.find("[")
end = text.rfind("]")
if start >= 0 and end > start:
try:
arr = json.loads(text[start : end + 1])
except json.JSONDecodeError:
_eprint("classify_items: could not parse classifier output")
return {}
else:
_eprint("classify_items: classifier returned no JSON array")
return {}
out: Dict[int, Dict[str, Any]] = {}
if isinstance(arr, list):
for obj in arr:
if not isinstance(obj, dict):
continue
idx = obj.get("index")
if isinstance(idx, int) and 0 <= idx < n_items:
out[idx] = obj
return out
def main() -> int:
parser = argparse.ArgumentParser(description="Classify items by urgency; emit only urgent ones.")
parser.add_argument("--criteria", required=True, help="Plain-language importance criteria.")
parser.add_argument("--threshold", type=int, default=7, help="Minimum score (0-10) to surface. Default 7.")
parser.add_argument("--input-file", default=None, help="Read items JSON from this file instead of stdin.")
parser.add_argument("--format", choices=["text", "json"], default="text", help="Output format for surfaced items.")
args = parser.parse_args()
items = _load_items(args.input_file)
if not items:
# Nothing to classify -> silent. This is the common quiet-interval case.
return 0
# Import here so --help works without the package importable.
try:
from agent.auxiliary_client import call_llm
except Exception as e: # pragma: no cover - import guard
_eprint(f"classify_items: cannot import auxiliary client: {e}")
return 3
prompt = _build_prompt(items, args.criteria)
try:
resp = call_llm(
task="monitor",
messages=[{"role": "user", "content": prompt}],
max_tokens=1024,
temperature=0,
)
content = resp.choices[0].message.content
if not isinstance(content, str):
content = str(content) if content else ""
except Exception as e:
# Classification failure is NOT silent -- surface it so a broken monitor
# doesn't quietly swallow important items. Non-zero exit -> cron alerts.
_eprint(f"classify_items: classifier call failed: {e}")
return 4
scores = _parse_scores(content, len(items))
surfaced = []
for i, item in enumerate(items):
s = scores.get(i)
score = s.get("score") if isinstance(s, dict) else None
if isinstance(score, int) and score >= args.threshold:
surfaced.append((i, item, s))
if not surfaced:
# Below threshold -> silent. Empty stdout; cron suppresses delivery.
return 0
if args.format == "json":
out = [
{
"id": _item_id(item, i),
"score": s.get("score"),
"reason": s.get("reason", ""),
"item": item,
}
for (i, item, s) in surfaced
]
print(json.dumps(out, ensure_ascii=False, indent=2))
else:
blocks = []
for (i, item, s) in surfaced:
title = (
item.get("title")
or item.get("subject")
or item.get("summary")
or _item_id(item, i)
)
url = item.get("url") or item.get("link") or ""
reason = s.get("reason", "")
block = f"## [{s.get('score')}/10] {title}"
if url:
block += f"\n{url}"
if reason:
block += f"\n_{reason}_"
blocks.append(block)
print("\n\n".join(blocks))
return 0
if __name__ == "__main__":
sys.exit(main())
+154
View File
@@ -0,0 +1,154 @@
"""Curated catalog of starter cron-job suggestions.
These are the built-in automations Hermes can offer a new user out of the box —
the ``catalog`` source of the unified suggestion surface. Each entry is a
ready-to-run ``cron.jobs.create_job`` spec wrapped as a suggestion; the user
accepts via ``/suggestions``. Nothing here auto-schedules.
The "important-mail monitor" entry is where the old proactive-monitor engine
lives now: its ``classify_items.py`` (poll a source -> LLM-score urgency ->
surface only above-threshold) is ONE catalog automation, not a standalone
feature.
Adding a catalog entry: append a CatalogEntry. Keep prompts self-contained
(cron jobs run with no chat context) and schedules sensible. The ``job_spec``
is passed verbatim to ``create_job`` on accept.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional
__all__ = ["CatalogEntry", "CATALOG", "seed_catalog_suggestions", "classify_items_script_path"]
def classify_items_script_path() -> str:
"""Absolute path to the urgency classifier script shipped with cron/."""
return str((Path(__file__).resolve().parent / "scripts" / "classify_items.py"))
@dataclass(frozen=True)
class CatalogEntry:
"""A curated starter automation offered as a suggestion."""
key: str # stable dedup key (never re-offered once dismissed)
title: str
description: str
job_spec: Dict[str, Any] # kwargs for cron.jobs.create_job
# The curated set. Schedules use the cron/interval syntax create_job accepts.
CATALOG: List[CatalogEntry] = [
CatalogEntry(
key="catalog:daily-briefing",
title="Daily briefing",
description="Every morning at 8am, a short briefing: today's calendar, "
"weather, and anything urgent waiting on you.",
job_spec={
"prompt": (
"Produce a concise morning briefing for the user: today's "
"calendar events, the local weather, and any urgent items "
"(unread important email, due tasks). Keep it short and "
"scannable. If you have no connected data sources, give a brief "
"general good-morning with the date and offer to connect "
"calendar/email."
),
"schedule": "0 8 * * *",
"name": "Daily briefing",
"deliver": "origin",
},
),
CatalogEntry(
key="catalog:important-mail-monitor",
title="Important-mail monitor",
description="Check your inbox periodically and ping you ONLY about mail "
"that actually needs attention — never the newsletters.",
job_spec={
"prompt": (
"Check the user's inbox for new messages since the last run. "
"For each candidate, judge urgency against this rule: surface "
"only mail that needs a reply today, is from a manager/family "
"member, or mentions a deadline. Pipe candidates through the "
"urgency classifier (run `python3 -m cron.scripts.classify_items "
"--threshold 7 --criteria ...` from the hermes-agent install — "
"resolve the script path at run time, do not assume a fixed "
"location) and deliver ONLY what it returns. If nothing "
"clears the bar, respond with [SILENT] so the user is not "
"pinged. Requires a connected mail source; if none is "
"configured, explain how to connect one and then stop."
),
"schedule": "every 30m",
"name": "Important-mail monitor",
"deliver": "origin",
},
),
CatalogEntry(
key="catalog:weekly-review",
title="Weekly review",
description="Every Sunday evening, a recap of the week: what got done, "
"what's still open, and what's coming up next week.",
job_spec={
"prompt": (
"Produce a weekly review for the user: summarize what was "
"accomplished this week, list still-open items, and preview "
"next week's calendar. Pull from whatever sources are connected "
"(calendar, task tools, recent conversations). Keep it tight."
),
"schedule": "0 18 * * 0",
"name": "Weekly review",
"deliver": "origin",
},
),
CatalogEntry(
key="catalog:standup-reminder",
title="Workday start reminder",
description="A weekday nudge at 9am with your day's agenda and top "
"priorities, so you start focused.",
job_spec={
"prompt": (
"Give the user a brief weekday start-of-day nudge: their "
"calendar for today and the 1-3 highest-priority things to "
"focus on, inferred from recent context and any task tools. "
"Encouraging, short, one message."
),
"schedule": "0 9 * * 1-5",
"name": "Workday start reminder",
"deliver": "origin",
},
),
]
def seed_catalog_suggestions(
*,
add_fn: Optional[Callable[..., Optional[Dict[str, Any]]]] = None,
keys: Optional[List[str]] = None,
) -> List[Dict[str, Any]]:
"""Register catalog entries as pending suggestions.
``add_fn`` defaults to ``cron.suggestions.add_suggestion`` (injectable for
tests). ``keys`` restricts to specific catalog entries; omit to seed all.
Entries already dismissed/accepted (by dedup key) or beyond the pending cap
are skipped by the store, so re-seeding is safe and idempotent. Returns the
list of suggestion records actually created.
"""
if add_fn is None:
from cron.suggestions import add_suggestion as add_fn # type: ignore[assignment]
wanted = set(keys) if keys else None
created: List[Dict[str, Any]] = []
for entry in CATALOG:
if wanted is not None and entry.key not in wanted:
continue
rec = add_fn(
title=entry.title,
description=entry.description,
source="catalog",
job_spec=dict(entry.job_spec),
dedup_key=entry.key,
)
if rec is not None:
created.append(rec)
return created
+281
View File
@@ -0,0 +1,281 @@
"""Suggested cron jobs — proposed automations the user accepts with one tap.
A *suggestion* is a ready-to-run cron job spec that Hermes surfaces to the
user, who accepts it (creates the real cron job) or dismisses it (latched so
it is never re-offered). This is the single surface every automation proposal
flows through, regardless of where it came from:
* ``catalog`` — a curated starter automation (daily briefing, important-mail
monitor, weekly digest, ...).
* ``blueprint`` — the user installed a skill that carries a ``blueprint:`` block
(see ``tools/blueprints.py``); installing it registers a
suggestion instead of auto-scheduling.
* ``usage`` — the background self-improvement review noticed a recurring
ask that a scheduled job would serve.
* ``integration`` — the user connected an account (Gmail, GitHub, ...) and
the obvious automations for that surface are offered.
Accepting a suggestion just calls the existing ``cron.jobs.create_job`` with
the stored ``job_spec`` — there is NO second job engine. Suggestions never
auto-create jobs; acceptance is always explicit (consent-first). Dismissed
suggestions latch by a stable ``dedup_key`` so the same proposal is not
re-offered after the user says no.
Storage mirrors ``cron/jobs.py``: ``~/.hermes/cron/suggestions.json``, atomic
writes, an in-process lock, and 0600 perms.
"""
from __future__ import annotations
import json
import logging
import os
import tempfile
import threading
import uuid
from pathlib import Path
from typing import Any, Dict, List, Optional
from hermes_constants import get_hermes_home
from hermes_time import now as _hermes_now
from utils import atomic_replace
logger = logging.getLogger(__name__)
# Per-profile by design (issue #4707): suggestions live alongside the active
# profile's cron store. Anchor on get_hermes_home() (profile home), not the
# shared default root. See cron/jobs.py for the full rationale.
#
# Optional test override. Production resolves the path at call time so
# multiplexed profile ticks (set_hermes_home_override) cannot leak one
# profile's suggestions into the import-time home (#86519). Same pattern as
# cron/executions.py.
SUGGESTIONS_FILE: Optional[Path] = None
def _current_suggestions_file() -> Path:
return SUGGESTIONS_FILE or (get_hermes_home().resolve() / "cron" / "suggestions.json")
# In-process lock protecting load->modify->save cycles (the background review
# fork and the main agent can both write).
_suggestions_lock = threading.Lock()
# Cap pending suggestions so the list never becomes a nag wall. When full,
# new suggestions are dropped (the user should clear the backlog first).
MAX_PENDING = 5
VALID_SOURCES = frozenset({"catalog", "blueprint", "usage", "integration"})
_STATUS_PENDING = "pending"
_STATUS_ACCEPTED = "accepted"
_STATUS_DISMISSED = "dismissed"
def _secure_file(path: Path) -> None:
try:
os.chmod(path, 0o600)
except OSError:
pass
def _ensure_dir() -> None:
from cron.jobs import _ensure_cron_dir
_ensure_cron_dir(_current_suggestions_file().parent)
def _load_raw() -> Dict[str, Any]:
suggestions_file = _current_suggestions_file()
if not suggestions_file.exists():
return {"suggestions": []}
try:
with open(suggestions_file, "r", encoding="utf-8") as f:
data = json.load(f)
except (json.JSONDecodeError, OSError) as e:
logger.warning("suggestions.json unreadable (%s); starting empty", e)
return {"suggestions": []}
if isinstance(data, dict) and isinstance(data.get("suggestions"), list):
return data
if isinstance(data, list):
return {"suggestions": data}
logger.warning("suggestions.json malformed; starting empty")
return {"suggestions": []}
def _save_raw(suggestions: List[Dict[str, Any]]) -> None:
_ensure_dir()
suggestions_file = _current_suggestions_file()
fd, tmp_path = tempfile.mkstemp(dir=str(suggestions_file.parent), suffix=".tmp", prefix=".sugg_")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(
{"suggestions": suggestions, "updated_at": _hermes_now().isoformat()},
f,
indent=2,
)
f.flush()
os.fsync(f.fileno())
atomic_replace(tmp_path, suggestions_file)
_secure_file(suggestions_file)
except BaseException:
try:
os.unlink(tmp_path)
except OSError:
pass
raise
def load_suggestions() -> List[Dict[str, Any]]:
"""Return all suggestion records (any status)."""
return _load_raw().get("suggestions", [])
def list_pending() -> List[Dict[str, Any]]:
"""Return pending suggestions in creation order (oldest first)."""
return [s for s in load_suggestions() if s.get("status") == _STATUS_PENDING]
def add_suggestion(
*,
title: str,
description: str,
source: str,
job_spec: Dict[str, Any],
dedup_key: str,
) -> Optional[Dict[str, Any]]:
"""Register a pending suggestion. Returns the record, or None if skipped.
Skipped when: the source is unknown, the same ``dedup_key`` was already
dismissed or accepted (never re-offer), an identical pending suggestion
exists, or the pending list is full (``MAX_PENDING``).
``job_spec`` is a dict of kwargs for ``cron.jobs.create_job`` — accepting
the suggestion passes it straight through, so there is no second schema to
keep in sync.
"""
if source not in VALID_SOURCES:
raise ValueError(f"unknown suggestion source: {source!r}")
if not title.strip() or not dedup_key.strip():
raise ValueError("title and dedup_key are required")
with _suggestions_lock:
suggestions = _load_raw().get("suggestions", [])
# Never re-offer something the user already saw and decided on, and
# never duplicate a still-pending proposal.
for existing in suggestions:
if existing.get("dedup_key") == dedup_key:
if existing.get("status") in (_STATUS_DISMISSED, _STATUS_ACCEPTED):
return None
if existing.get("status") == _STATUS_PENDING:
return None
pending_count = sum(1 for s in suggestions if s.get("status") == _STATUS_PENDING)
if pending_count >= MAX_PENDING:
logger.info("Suggestion backlog full (%d); dropping %r", MAX_PENDING, title)
return None
record = {
"id": uuid.uuid4().hex[:12],
"title": title.strip(),
"description": description.strip(),
"source": source,
"job_spec": job_spec,
"dedup_key": dedup_key.strip(),
"status": _STATUS_PENDING,
"created_at": _hermes_now().isoformat(),
}
suggestions.append(record)
_save_raw(suggestions)
return record
def get_suggestion(ref: str) -> Optional[Dict[str, Any]]:
"""Resolve a suggestion by id, 1-based pending index, or title (exact)."""
suggestions = load_suggestions()
# By id.
for s in suggestions:
if s.get("id") == ref:
return s
# By 1-based pending index.
if ref.isdigit():
pending = [s for s in suggestions if s.get("status") == _STATUS_PENDING]
idx = int(ref) - 1
if 0 <= idx < len(pending):
return pending[idx]
# By exact title (case-insensitive).
for s in suggestions:
if s.get("title", "").lower() == ref.lower():
return s
return None
def _set_status(suggestion_id: str, status: str) -> bool:
with _suggestions_lock:
suggestions = _load_raw().get("suggestions", [])
changed = False
for s in suggestions:
if s.get("id") == suggestion_id:
s["status"] = status
s["resolved_at"] = _hermes_now().isoformat()
changed = True
break
if changed:
_save_raw(suggestions)
return changed
def dismiss_suggestion(ref: str) -> bool:
"""Dismiss a suggestion (latched — never re-offered for its dedup_key)."""
s = get_suggestion(ref)
if not s:
return False
return _set_status(s["id"], _STATUS_DISMISSED)
def accept_suggestion(ref: str, *, origin: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]:
"""Accept a suggestion: create the real cron job from its ``job_spec``.
Returns the created cron job dict, or None if the suggestion isn't found /
not pending. The job_spec is passed straight to ``cron.jobs.create_job``;
an ``origin`` (platform/chat) is merged so "origin" delivery routes back to
the chat where the user accepted.
"""
s = get_suggestion(ref)
if not s or s.get("status") != _STATUS_PENDING:
return None
from cron.scheduler import (
CronSchedulerRegistrationError,
create_job_with_scheduler_registration,
)
spec = dict(s.get("job_spec") or {})
if origin is not None and "origin" not in spec:
spec["origin"] = origin
try:
job = create_job_with_scheduler_registration(**spec)
except CronSchedulerRegistrationError:
# The job is already durable. Resolve the suggestion so retrying the
# same acceptance cannot create another local copy.
_set_status(s["id"], _STATUS_ACCEPTED)
raise
_set_status(s["id"], _STATUS_ACCEPTED)
return job
def clear_resolved() -> int:
"""Drop accepted/dismissed records from disk. Returns the count removed.
Pending suggestions and the dedup memory of dismissed ones are the only
things that matter long-term, but dismissed records must be RETAINED for
their dedup_key (so they aren't re-offered). This only prunes ACCEPTED
records, which have served their purpose once the job exists.
"""
with _suggestions_lock:
suggestions = _load_raw().get("suggestions", [])
kept = [s for s in suggestions if s.get("status") != _STATUS_ACCEPTED]
removed = len(suggestions) - len(kept)
if removed:
_save_raw(kept)
return removed