> For the complete documentation index, see [llms.txt](https://docs.labs.ai/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.labs.ai/conversations-and-orchestration/scheduling.md).

# Scheduled Execution & Heartbeats

## Overview

EDDI supports **scheduled agent execution** — agents can be triggered automatically on a timer without any user input. This enables proactive agents, background maintenance, periodic data processing, and memory consolidation.

### Use Cases

| Use Case                | Description                                                                                 |
| ----------------------- | ------------------------------------------------------------------------------------------- |
| **Proactive Agents**    | Check for updates, send notifications, or perform monitoring at regular intervals           |
| **Dream Consolidation** | Background memory maintenance — prune stale entries, detect contradictions, summarize facts |
| **Data Pipelines**      | Periodically fetch data from external APIs and process it through the agent pipeline        |
| **Health Checks**       | Run diagnostic agents that verify system health and report anomalies                        |
| **Report Generation**   | Generate daily/weekly summary reports through conversational agents                         |

## Concepts

### Schedule

A **Schedule** defines when and how often an agent fires:

```json
{
  "agentId": "agent-123",
  "agentVersion": 0,
  "triggerType": "CRON",
  "cronExpression": "0 2 * * *",
  "conversationStrategy": "persistent",
  "message": "Run maintenance cycle",
  "userId": "system:scheduler",
  "timeZone": "Europe/Vienna",
  "enabled": true
}
```

### Trigger Types

| Type        | Description                        | Default Strategy | Example                    |
| ----------- | ---------------------------------- | ---------------- | -------------------------- |
| `CRON`      | Wall-clock aligned cron expression | `new`            | `0 2 * * *` (daily at 2am) |
| `HEARTBEAT` | Fixed-interval, drift-proof        | `persistent`     | Every 300 seconds          |

A `CRON` schedule carrying `oneTimeAt` instead of `cronExpression` fires **once** at that instant rather than recurring. Exactly one of the two is required.

### Conversation Strategies

A schedule belongs to its agent, not to a version: a `new` fire starts on the latest deployed version, and a `persistent` conversation follows compatible versions like any other conversation (see [Running conversations and new agent versions](/conversations-and-orchestration/deployment-management-of-agents.md#running-conversations-and-new-agent-versions)). Undeploying a version disables the agent's schedules only when no version of the agent is left deployed in that environment — retiring an old version after deploying a new one leaves them running. The schedules it disables are marked as such (`disabledReason: "agent-undeployed"`), and the next **successful** deploy re-enables exactly those: a schedule you switched off yourself stays off, and so does one disabled because its creator lost access (below). A deploy that ends in `ERROR` (an unloadable workflow, a refused vault grant) neither announces the agent as ready nor re-enables anything. HITL approval timeouts are not disabled by an undeploy at all — they belong to the waiting conversation (see [hitl.md](/conversations-and-orchestration/hitl.md)).

With workspaces enforced, every fire re-checks that the schedule's **creator** may still use its agent — the USE gate runs at create time, but a fire runs with server identity. The check honours shares with the teams the creator belonged to when the schedule was created; it cannot see them leave a team later, since nothing server-side knows that without their token. A fire whose creator lost access (grant revoked, agent unshared or unpublished) is recorded `FAILED` with the reason and the schedule is disabled (`disabledReason: "access-revoked"`); it does not come back on a redeploy — re-enable it once access is restored, and the next fire checks again. Schedules with no recorded creator or no access snapshot (rows created before this check existed), team cadences, ingestion and HITL timeouts are not re-checked this way.

**Upgrading:** a schedule that a release before this one disabled on undeploy carries no `disabledReason`, so it is indistinguishable from one you switched off yourself and is **not** re-enabled by the next deploy — enable it once by hand (`POST .../schedules/{id}/enable`); schedules disabled from now on are marked and come back on their own.

| Strategy     | Behavior                                                                                                                                                                                                                                                                                                                                                                                                                          | Use When                                                 |
| ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- |
| `persistent` | Reuses the same conversation across all fires. Context accumulates — and so does the conversation document, one step per fire; see [Long-running persistent schedules](#long-running-persistent-schedules). If that conversation has ended (the idle sweep, or an undeploy with `endAllActiveConversations`), the next fire starts a fresh one and records it on the schedule instead of failing until the schedule dead-letters. | Dream consolidation, ongoing monitoring, stateful agents |
| `new`        | Creates a fresh conversation for each fire. Clean context each time.                                                                                                                                                                                                                                                                                                                                                              | Report generation, data pipelines, stateless tasks       |

## Configuration

### Creating a Schedule

```bash
curl -X POST http://localhost:7070/schedulestore/schedules \
  -H "Content-Type: application/json" \
  -d '{
    "agentId": "agent-123",
    "agentVersion": 0,
    "triggerType": "CRON",
    "cronExpression": "*/30 * * * *",
    "conversationStrategy": "persistent",
    "message": "heartbeat ping",
    "timeZone": "UTC",
    "enabled": true
  }'
```

### Cron Expression Reference

EDDI uses **standard 5-field cron expressions**:

```
┌────── minute (0-59)
│ ┌──── hour (0-23)
│ │ ┌── day of month (1-31)
│ │ │ ┌ month (1-12)
│ │ │ │ ┌ day of week (0-7, 0=Sun)
│ │ │ │ │
* * * * *
```

**Common patterns:**

| Expression     | Schedule                            |
| -------------- | ----------------------------------- |
| `0 2 * * *`    | Daily at 2:00 AM                    |
| `*/30 * * * *` | Every 30 minutes                    |
| `0 */4 * * *`  | Every 4 hours                       |
| `0 9 * * 1-5`  | Weekdays at 9:00 AM                 |
| `0 0 1 * *`    | First day of each month at midnight |

### Heartbeat Configuration

For heartbeat triggers, use `heartbeatIntervalSeconds` instead of `cronExpression`:

```json
{
  "agentId": "agent-123",
  "triggerType": "HEARTBEAT",
  "heartbeatIntervalSeconds": 300,
  "conversationStrategy": "persistent",
  "message": "heartbeat check",
  "enabled": true
}
```

Heartbeats are **drift-proof** — the next fire is the time this fire was *due* plus the interval, not the moment the turn happened to finish. A 40 s turn on a 60 s heartbeat still fires every 60 s. (`lastFired + interval` would *be* the drifting formula: `lastFired` is the completion instant. The one exception is a fire that overran a whole interval — anchoring on the due time would put the next fire in the past, which is a re-fire loop rather than catching up, so it is clamped to `now + interval`.)

### Schedule Fields

| Field                      | Type    | Default            | Description                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      |
| -------------------------- | ------- | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `agentId`                  | string  | required           | Agent to trigger                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `agentVersion`             | int     | `0` (latest)       | Agent version (0 = latest deployed)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| `triggerType`              | enum    | `CRON`             | `CRON` or `HEARTBEAT`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            |
| `cronExpression`           | string  | —                  | 5-field cron (for CRON type)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| `heartbeatIntervalSeconds` | long    | —                  | Interval in seconds (for HEARTBEAT type)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| `conversationStrategy`     | string  | varies             | `new` or `persistent`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            |
| `message`                  | string  | —                  | Message text sent to the agent on each fire                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      |
| `userId`                   | string  | `system:scheduler` | User identity for the fire                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       |
| `timeZone`                 | string  | `UTC`              | IANA timezone (e.g., `Europe/Vienna`). Across a DST change a fixed-time cron (no `*` in the minute or hour field, e.g. `30 2 * * *`) fires once per day: a local time the clocks skip fires at the moment of the transition, a local time they repeat fires only at its first occurrence. Wildcard crons (`*/15 * * * *`, `5 * * * *`) keep their real-time cadence. Two consequences of that (Vixie cron) rule: two fixed times that both land on the transition fire once — `0 2,3 * * *` fires a single time, at 03:00, on the spring-forward day — and a fixed hour *range* such as `0 0-23 * * *` or `0 1-5 * * *` counts as fixed-time, so on the 25-hour fall-back day it fires 24 times and does not repeat the doubled hour. Use `0 * * * *` for a truly hourly cadence |
| `environment`              | string  | `production`       | Deployment environment                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| `enabled`                  | boolean | `true`             | Whether the schedule is active. Set on create only: a `PUT` ignores it and keeps the stored value — change it with `/enable` and `/disable` (see [Enabling and disabling](#enabling-and-disabling))                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| `maxCostPerFire`           | double  | `-1` (unlimited)   | Dollar ceiling per fire                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| `oneTimeAt`                | string  | —                  | ISO-8601 instant for a single fire. Mutually exclusive with `cronExpression`; exactly one of the two is required for a `CRON` trigger                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            |
| `metadata`                 | object  | —                  | Free-form markers read by the fire executor. `{"dreamType": "dream_consolidation"}` dispatches the fire to the Dream service — see [Scheduling a Dream Cycle](/architecture-and-concepts/user-memory.md#scheduling-a-dream-cycle). A `PUT` that omits `metadata` keeps the stored value (so does one that omits `tenantId` or `allowSelfScheduling`); send `"metadata": null` or `{}` to clear it. A `PUT` to a RAG-ingestion schedule is refused (`409`) — change the source's cron on the knowledge base — and one to a team-cadence schedule needs EDIT on the group                                                                                                                                                                                                          |

### Managing Schedules

| Method   | Path                                    | Description                                                                                                                                                                                                                       |
| -------- | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `POST`   | `/schedulestore/schedules`              | Create a schedule. A body that fails validation is `400` with `{"error":"invalid_schedule","message":"<reason>"}` — e.g. `Cron expression must have exactly 5 fields (min hour dom month dow), got 6`; `PUT` answers the same way |
| `GET`    | `/schedulestore/schedules`              | List schedules, newest first (optional `?agentId=` filter; `?limit=` default 500, max 1000; `?offset=` default 0)                                                                                                                 |
| `GET`    | `/schedulestore/schedules/{id}`         | Get a specific schedule                                                                                                                                                                                                           |
| `PUT`    | `/schedulestore/schedules/{id}`         | Update a schedule's configuration. Does **not** change `enabled`                                                                                                                                                                  |
| `DELETE` | `/schedulestore/schedules/{id}`         | Delete a schedule                                                                                                                                                                                                                 |
| `POST`   | `/schedulestore/schedules/{id}/enable`  | Enable a schedule                                                                                                                                                                                                                 |
| `POST`   | `/schedulestore/schedules/{id}/disable` | Disable a schedule                                                                                                                                                                                                                |
| `POST`   | `/schedulestore/schedules/{id}/fire`    | Manually trigger a fire immediately                                                                                                                                                                                               |

> **Paging (wire change).** The listing used to be one hard-capped page of 500 in whatever order the store returned; past that, the surplus schedules could not be found, disabled or deleted through the list at all. It is now ordered (newest `createdAt` first, id breaking ties) and takes `limit`/`offset`. A response holding exactly `limit` entries may be truncated — ask for the next page to find out.
>
> A non-admin's listing holds only the schedules that run as the caller plus shared ones (no `userId`, or the `system:scheduler` placeholder), refined for team cadences and workspaces as described in [Who Can See and Manage a Schedule](#who-can-see-and-manage-a-schedule), and HITL approval timeouts are left out. Both restrictions are part of the query, so `limit`/`offset` count only the rows the caller can see and a short page really is the last one.
>
> **`limit=0` is now `400`, on all three listing endpoints.** It used to be passed through to the store, where the two backends read it opposite ways: the MongoDB driver treats `limit(0)` as *no limit* and dumped every row, while PostgreSQL's `LIMIT 0` returned nothing. A client that sent `limit=0` and got away with it must send a positive value.
>
> **Firing a one-shot consumes it.** `POST /{id}/fire` runs the same state machine a polled fire does, so a successful manual fire of a `oneTimeAt` schedule disables it — it is the run, not a rehearsal. Re-arm it with `POST /{id}/enable`.
>
> **Firing a heartbeat manually consumes its next scheduled fire.** Same reason: a successful fire re-arms the schedule from the fire it was *due* to make, so firing a daily heartbeat by hand in the morning moves the next one to a day after that due time — tonight's run is skipped, not brought forward.
>
> **A&#x20;*****skipped*****&#x20;manual fire does not.** If the coordinator drops the turn because the conversation is busy or `AWAITING_HUMAN`, nothing was delivered, so nothing is consumed: a due time still in the future is left exactly where it was and tonight's run happens as configured. Only a due time that has already passed is rolled forward to the next cadence.
>
> A manual fire is **synchronous**: the request holds open until the turn finishes or `eddi.schedule.fire-timeout` (default 5 minutes) elapses, so a proxy or client with a shorter read timeout may give up before the fire log comes back. The fire itself continues, and the schedule stays claimed until it ends — a retry in the meantime answers `409`.

### Enabling and disabling

`enabled` is a switch, not configuration, and only `POST /{id}/enable` and `POST /{id}/disable` flip it (a finished one-shot is also disabled by its own fire). A `PUT` keeps the stored value whatever its body says — `"enabled": true`, `"enabled": false` or no `enabled` at all — and the store leaves the field out of the same atomic write, so there is no window in which a stale value can land.

This is what makes concurrent editing safe. An editor that read a schedule, while another operator disabled it before the editor pressed Save, used to send its stale `"enabled": true` and silently re-enable the schedule, letting it fire again. Now the save changes only what the form edits and the schedule stays disabled. Leaving `enabled` out of the body would not have been enough on its own: the field defaults to `true`, so an omitted value read as "enable".

Two consequences worth knowing:

* Editing a one-shot that has already fired (and so disabled itself) does not re-arm it. Save the new `oneTimeAt`, then `POST /{id}/enable`.
* An agent import with `strategy=merge` that updates an existing schedule keeps that schedule's `enabled` state on the target, as it keeps its owner.

The Manager's schedule editor sends no `enabled` on an edit; the row's toggle calls `/enable` or `/disable`.

### Who Can See and Manage a Schedule

With authorization on, schedule access follows who the schedule **runs as**:

* A schedule whose `userId` names a real user (a per-user dream schedule, a schedule created with your own `userId`) belongs to that user. Other non-admin callers do not see it in the listing, get `403` reading it or its fire logs, and cannot update, fire, enable, disable, delete, retry or dismiss it — **with workspaces on or off**. Administrators see and manage everything. HITL approval timeouts are hidden from non-admins on a direct read too, as in the listing.
* A **team cadence** schedule runs as the cadence's creator but belongs to its group: callers with VIEW on the group can read it, and callers with EDIT can enable, disable or delete it. Firing or re-pointing it still takes the creator (or an admin), and firing also needs EDIT on the group. With workspaces off every cadence is listed; with them on, a co-editor reaches another member's cadence by id or through the group workspace, not through the listing. A create or update whose body carries the cadence marker (`teamCadenceType`) is refused with `400`, unless it is an update of that same cadence echoing its own markers.
* A **shared** schedule (`userId` absent, blank or `system:scheduler`) is open to every editor while workspaces are off. With workspaces enforced it belongs to its creator (`createdBy`) and to whoever holds EDIT (VIEW, to read it) on what it drives: the agent, the knowledge base of an ingestion schedule, or the group of a cadence.
* **Listing under enforcement.** The listing filter runs inside the database query, so it cannot ask about agent access row by row. Without `?agentId=`, a non-admin sees their own schedules and the shared ones they created. To see a team's other shared schedules — including ones created before `createdBy` was recorded — list with `?agentId=` of an agent you may edit, or open them by id.
* The failed-fires view (`/admin/failed`) shows non-admins only the entries of schedules they can see, so a page can hold fewer entries than `limit`.
* Agent export and its preview leave out schedules that run as another user.

### Admin Endpoints

| Method | Path                                    | Description                                                                                                                                                                                                                                                                                                                                                                                 |
| ------ | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GET`  | `/schedulestore/schedules/{id}/fires`   | Read fire history, newest first (`?limit=` default 20, must be > 0, capped at 500)                                                                                                                                                                                                                                                                                                          |
| `GET`  | `/schedulestore/schedules/admin/failed` | List failed/dead-lettered fires (`?limit=` default 50, must be > 0, capped at 500); non-admins get only the entries of schedules they can see                                                                                                                                                                                                                                               |
| `POST` | `/schedulestore/schedules/{id}/retry`   | Re-queue a dead-lettered schedule                                                                                                                                                                                                                                                                                                                                                           |
| `POST` | `/schedulestore/schedules/{id}/dismiss` | Reset dead-letter without immediate retry, re-armed at its next regular fire. It does **not** change `enabled`: a schedule that was disabled (by `/disable`, or because its agent was undeployed) stays disabled — use `/enable` to re-arm it. `409` unless the schedule is currently `DEAD_LETTERED` — the write itself is conditional on that state, so it can never reset a running fire |

## Dream Consolidation

Dream Consolidation is a specialized schedule that performs **background memory maintenance** on an agent's persistent user memories. It takes two pieces: the agent's `userMemoryConfig.dream` block says *how* to consolidate (and `enabled` is its on/off switch), and a schedule says *when* and *for which user* — nothing runs without one (see [Scheduling a Dream Cycle](/architecture-and-concepts/user-memory.md#scheduling-a-dream-cycle)).

### What It Does

1. **Stale entry pruning** — Removes outdated facts that are no longer relevant
2. **Contradiction detection** — Identifies conflicting memories (e.g., "user likes coffee" vs "user hates coffee") and logs them for review. Resolution is planned for a future version.
3. **Fact summarization** — Consolidates verbose entries into concise summaries

### Configuration

The consolidation settings live in the agent configuration (the agent document itself — `userMemoryConfig` is a top-level field, not nested under any wrapper):

```json
{
  "enableMemoryTools": true,
  "userMemoryConfig": {
    "dream": {
      "enabled": true,
      "detectContradictions": true,
      "contradictionResolution": "keep_newest",
      "pruneStaleAfterDays": 90,
      "summarizeInteractions": true,
      "summarizeMinEntries": 5,
      "summarizeTargetEntries": 2,
      "summarizeGroupBy": "category",
      "preserveAgentProvenance": false,
      "llmProvider": "anthropic",
      "llmModel": "claude-sonnet-4-6",
      "parameters": { "apiKey": "${vault:anthropic-api-key}" },
      "maxCostPerRun": 0.50
    }
  }
}
```

And the schedule that runs it — one per user, created with `POST /schedulestore/schedules`:

```json
{
  "name": "nightly-dream",
  "triggerType": "CRON",
  "cronExpression": "0 3 * * *",
  "agentId": "<agentId>",
  "userId": "<userId>",
  "metadata": { "dreamType": "dream_consolidation" }
}
```

`dream.schedule` in the agent config is never read — the cron lives on the schedule. `batchSize` and `maxUsersPerRun` are accepted for compatibility but not read either: a dream fire consolidates exactly the one user its schedule names. A long Dream run is bounded by `eddi.schedule.fire-timeout` like any other fire — raise it if your consolidations legitimately take longer (the lease follows it).

> **Scope:** a dream cycle only touches memories the **firing agent** wrote (`sourceAgentId`). Set `crossAgentMaintenance: true` to maintain the user's whole memory set across agents — without it, agent A's `pruneStaleAfterDays` would delete agent B's memories and A's model endpoint would see B's private text.
>
> `maxSummarizationCalls` is **deprecated** in favour of `maxCostPerRun` (a call count is a poor budget — consolidations differ wildly in cost). It is still honoured as a secondary backstop if a stored config sets it explicitly, so existing configurations keep their bound.

### Cost Control

Dream cycles consume LLM tokens. Use `maxCostPerRun` (in the **Agent Configuration**) to set a dollar ceiling per run:

```json
{
  "userMemoryConfig": {
    "dream": {
      "maxCostPerRun": 0.50
    }
  }
}
```

When the budget is exceeded, the agent stops processing. This prevents runaway costs on large memory stores.

> **Tip:** Use a cheaper model (e.g., `claude-sonnet-4-6` or `gpt-4o-mini`) for dream consolidation — the task doesn't require top-tier reasoning.

## Fire Logging

Every scheduled execution is logged. View fire history via the REST API:

```bash
# List recent fires for a schedule
curl http://localhost:7070/schedulestore/schedules/{scheduleId}/fires?limit=20

# List all failed fires across all schedules
curl http://localhost:7070/schedulestore/schedules/admin/failed?limit=50
```

> **What `cost` means depends on the fire path, and the two are not the same quantity.** A conversation fire reports the **tool** spend of that turn — the `ToolCostTracker` delta — so a schedule whose agent only talks to the model, with no tool calls, reports `0.00` however many tokens it used. A dream consolidation fire reports its own **estimated LLM** cost. Compare a fire log against others on the same path, and use `maxCostPerFire` / `maxCostPerRun` rather than the logged number to bound spend.

> **A RAG ingestion fire starts a run; it does not wait for it.** The fire claims the source's run and hands it to a worker of its own, then logs `COMPLETED` — a crawl outlasts the scheduler's lease, and running it inside the fire meant the scheduler cancelled it mid-crawl. When the run later **fails**, its worker writes a second entry for the same fire with status `FAILED` and the run's error, so a crawl that fails every night shows up in the fire log (and in `admin/failed`). That entry does **not** raise the schedule's `failCount`, so a failing crawl never retries early and never dead-letters; the run's own history, under the knowledge base's source, has the detail. A fire that finds a run still going logs `COMPLETED` with "already running". On a graceful shutdown, runs in flight on that instance are closed as `CANCELLED`, so the next fire or "Run now" is not refused until they would have been reaped. See [rag.md](/agent-configuration/rag.md#ingestion-sources).

### Fire Logs and Erasure

A fire log carries the `conversationId` of the turn it started, and it is only findable by its `scheduleId` — so a fire log whose schedule has been deleted is personal data that nothing can reach again. Deleting a schedule therefore always deletes its fire logs, on the single-schedule path and on all three bulk paths (by agent, by name, and the GDPR erasure by user).

Two mechanisms keep that true even while the schedule is firing:

* **The write is conditional.** A fire log is stored only if its schedule still exists at the moment of the write — on PostgreSQL an `INSERT … WHERE EXISTS (SELECT 1 FROM eddi_schedules WHERE id = ?)`, on MongoDB (which has no conditional insert) an insert that is verified against the schedule immediately afterwards and removed again if it has gone. A fire in flight when an erasure runs simply writes no log. The fire itself is unaffected; only its log is dropped.
* **The delete sweeps twice.** The cascade removes the logs that exist when it runs — on PostgreSQL in the same transaction as the schedule delete — and a second indexed pass runs after the schedules are gone. That remains the belt-and-braces for a log written by an instance that had not yet observed the delete.

The consequence for operators: a schedule deleted mid-fire may lose the fire log for that one attempt. That is deliberate — the alternative is an unreachable record of an erased user's conversation.

### State Machine

Each schedule follows a state machine:

```
PENDING → CLAIMED → EXECUTING → COMPLETED
                              → SKIPPED → (re-arm, no failure counted) → PENDING
                              → FAILED → (retry) → PENDING
                              → DEAD_LETTERED → (manual retry/dismiss)
```

`SKIPPED` is a **fire-log status only** — it is never stored on the schedule itself. It means the coordinator dropped the scheduled turn without consuming the input because the conversation was already busy or `AWAITING_HUMAN`. That is the normal state of a `persistent` heartbeat while a human is chatting in its conversation, or while a previous fire waits on a HITL approval, so a skip is logged and counted but **never** enters the retry/backoff/dead-letter machine: `failCount` is left exactly as it was and the claim is released.

Re-arming is deliberately conservative, because a skip delivered nothing:

* **Due time already passed** (every polled skip, and a manual fire of an overdue schedule) → advanced to the next cadence, on the same drift-proof anchor a successful fire uses.
* **Due time still in the future** (only reachable through `POST /{id}/fire`, which claims regardless of `nextFire`) → left untouched. The pending fire *is* the next cadence, so advancing past it would silently cancel a scheduled delivery that nothing replaced.
* **A one-shot whose moment has passed** has no cadence to re-arm to, so it does go through retry/backoff — its single delivery genuinely never happened.

## Cluster Awareness

The `SchedulePollerService` is cluster-aware — in multi-instance deployments, only one instance executes each scheduled fire. This is achieved via atomic claim operations (`tryClaim`), preventing duplicate execution when running EDDI behind a load balancer.

Two consequences are worth stating plainly, because they shape how a scheduled target must be written:

* **Claiming is per-lease compare-and-set**, so exactly one instance wins a given poll. But **delivery is at-least-once**: if the winner dies mid-fire, the lease expires (`eddi.schedule.lease-timeout`) and another instance re-claims the same fire. **Scheduled targets must be idempotent.**
* Instances identify themselves by `eddi.schedule.instance-id`, which is auto-derived from the hostname when left empty. In an environment where hostnames are recycled or duplicated (some container schedulers), set it explicitly — two instances sharing an ID makes claim ownership ambiguous.

## Deployment Configuration

Individual schedules are configuration documents; the *poller* that runs them is tuned deployment-wide in `application.properties` (or the matching environment variables — Quarkus maps `eddi.schedule.poll-interval` to `EDDI_SCHEDULE_POLL_INTERVAL` — every non-alphanumeric character becomes `_`).

| Property                                          | Default      | What it controls                                                                                                                                                                                                                                                                                                |
| ------------------------------------------------- | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `eddi.schedule.enabled`                           | `true`       | Master switch. `false` stops all polling — schedules remain stored and simply never fire                                                                                                                                                                                                                        |
| `eddi.schedule.poll-interval`                     | `15s`        | How often each instance looks for due schedules. This is the floor on firing punctuality: a schedule due at `12:00:00` fires somewhere in `[12:00:00, 12:00:15)`                                                                                                                                                |
| `eddi.schedule.poll-batch-size`                   | `100`        | Max schedules claimed per poll cycle. Claimed schedules dispatch concurrently on virtual threads; raise it to drain large bursts (e.g. many one-shot HITL approval timeouts expiring together)                                                                                                                  |
| `eddi.schedule.lease-timeout`                     | `10m`        | How long a claimed schedule is considered owned before another instance may re-claim it. It must outlast `fire-timeout` plus a 30 s grace, or a schedule becomes reclaimable while its fire is still running and fires twice; a shorter value is raised at startup to `fire-timeout + 30s + 1m`, with a warning |
| `eddi.schedule.max-retries`                       | `5`          | Attempts before a fire is `DEAD_LETTERED`                                                                                                                                                                                                                                                                       |
| `eddi.schedule.backoff-base-seconds`              | `15`         | Retry delay = `base × multiplier^(attempt-1)` seconds                                                                                                                                                                                                                                                           |
| `eddi.schedule.backoff-multiplier`                | `4`          | With the defaults: 15s, 60s, 4m, 16m, 64m                                                                                                                                                                                                                                                                       |
| `eddi.schedule.min-interval-seconds`              | `60`         | Smallest cron interval a schedule may request. Guards against schedule bombing; a rejected create returns a message naming this property                                                                                                                                                                        |
| `eddi.schedule.instance-id`                       | *(hostname)* | Identity used for cluster claim tracking                                                                                                                                                                                                                                                                        |
| `eddi.schedule.default-timezone`                  | `UTC`        | IANA zone applied to schedules that do not name one                                                                                                                                                                                                                                                             |
| `eddi.schedule.fire-timeout`                      | `5m`         | How long one fire — a conversation turn, a Dream run, a team-cadence pull — may run before it is interrupted and recorded `FAILED` with "the fire exceeded eddi.schedule.fire-timeout", then retried with backoff. Raise it for long Dream runs; `lease-timeout` follows                                        |
| `eddi.schedule.hitl-timeout-retry-interval`       | `2m`         | When a HITL approval timeout cannot be applied yet (store blip, node draining, a decision still in flight), how long until it is tried again. These are re-armed, never dead-lettered — see [hitl.md](/conversations-and-orchestration/hitl.md)                                                                 |
| `eddi.schedule.persistent-conversation-max-steps` | `0` (off)    | Steps after which a `persistent` schedule ends its conversation and starts a new one — see [Long-running persistent schedules](#long-running-persistent-schedules)                                                                                                                                              |
| `eddi.schedule.fire-log-retention`                | `90d`        | Fire logs older than this are deleted by a periodic sweep. `0` keeps everything — note that a 60-second heartbeat alone writes \~525,600 rows a year                                                                                                                                                            |
| `eddi.schedule.fire-log-prune-interval`           | `1h`         | How often that sweep runs. The DELETE is by timestamp and therefore idempotent, so it needs no cluster claim                                                                                                                                                                                                    |

### Long-running persistent schedules

A `persistent` schedule appends one step to the same conversation on every fire. A MongoDB document cannot exceed 16 MB, and a conversation past that limit can no longer be written: every fire after it is lost. A 60-second heartbeat that stores a few KB per turn gets there in weeks.

Once a persistent conversation passes 1000 steps, EDDI logs a WARN naming it (once per conversation). To bound it, set `eddi.schedule.persistent-conversation-max-steps`. When the conversation reaches that many steps **and is idle** (`READY`, `ERROR` or `EXECUTION_INTERRUPTED`), the next fire ends it and starts a new one:

* The old conversation is ended, not trimmed; its full history stays readable.
* `conversation`-scoped properties are copied into the new conversation (anything its own start turn set wins), provided the old conversation belongs to the schedule's current `userId` — state written for another user is never handed on. `longTerm` properties need no copying — they live in user memory.
* If ending the old conversation fails, the fire keeps using it and a later idle fire tries the rollover again, so no conversation is left open beside its replacement.
* The model's **conversation history does not carry over**: the new conversation's LLM context starts empty. An agent that needs facts across a rollover should keep them in properties.
* A conversation that is `AWAITING_HUMAN` or busy is never rolled over — that would end the pending approval or the running turn. The fire is skipped as usual and the rollover happens on a later, idle fire.

The setting applies to every persistent schedule, including those already past the limit when it is enabled: each rolls over on its first idle fire after that.

Independent of the setting, a persistent schedule only replaces its conversation when that conversation is provably gone (the store reports it missing, or it belongs to another agent) or has `ENDED`. A store that cannot be read fails the fire instead, and the retry runs against the same conversation — a brief outage does not cost the schedule its history.

### Observability

| Metric                            | Type    | Read it for                                                                                                                                                                                                 |
| --------------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `eddi.schedule.poll.count`        | Counter | Poller liveness. Flat means the poller is not running — check `eddi.schedule.enabled`                                                                                                                       |
| `eddi.schedule.fire.count`        | Counter | Fires executed                                                                                                                                                                                              |
| `eddi.schedule.fire.failed`       | Counter | Fires that raised. Compare against `fire.count` for a failure rate                                                                                                                                          |
| `eddi.schedule.fire.skipped`      | Counter | Fires dropped because the target conversation was busy or awaiting a human. Not failures and never dead-lettered, but a heartbeat that only ever skips is delivering nothing — compare against `fire.count` |
| `eddi.schedule.fire.deadlettered` | Counter | Fires that exhausted `max-retries`. **Alert on any increase** — these need manual retry or dismissal                                                                                                        |
| `eddi.schedule.fire.duration`     | Timer   | If p99 approaches `fire-timeout`, fires are about to be cut off                                                                                                                                             |
| `eddi.schedule.fire.timedout`     | Counter | Fires interrupted at `fire-timeout`. Each one is a FAILED fire that will be retried — raise the timeout if the work is legitimately long                                                                    |
| `eddi.schedule.claim.conflict`    | Counter | Instances racing for the same schedule. Normal and expected in a cluster; a sharp rise alongside falling `fire.count` suggests contention rather than work                                                  |
| `eddi.schedule.firelog.pruned`    | Counter | Fire logs removed by the retention sweep. Flat while the table grows means retention is disabled (`fire-log-retention=0`) or the sweep is failing — check the logs                                          |

## Best Practices

1. **Start with longer intervals** — Begin with hourly or daily schedules and increase frequency only if needed
2. **Use `persistent` strategy for stateful work** — Dream consolidation and monitoring agents benefit from accumulated context
3. **Set cost ceilings** — Always configure `maxCostPerFire` or `maxCostPerRun` for LLM-powered scheduled tasks
4. **Monitor fire logs** — Check for recurring failures that might indicate configuration issues
5. **Use cheap models for maintenance** — Background tasks rarely need expensive frontier models

## See Also

* [Managed Agents](/conversations-and-orchestration/managed-agents.md) — Intent-based agent routing
* [User Memory](/architecture-and-concepts/user-memory.md) — Persistent user memory (target of dream consolidation)
* [LLM Configuration](/agent-configuration/langchain.md) — Agent configuration reference
* [Metrics](/deployment-and-infrastructure/metrics.md) — Monitoring scheduled execution performance
