Skip to content

Commit c39c765

Browse files
authored
Persistent cron rotation starts a new chat, preserving the old one (#167)
Rotation used to reset the SDK context in place: every context epoch piled into one endless cron:{job_id} chat, and since the chat view only loads the most recent messages, pre-rotation history was effectively invisible. Now each persistent job runs in a generation chat (cron:{job_id}:{ts}) tracked via channel_sessions. Rotation retires the current chat untouched (history, usage, and run-log links preserved; browsable like any session), cancels its pending wakeups, stamps/retitles it, and mints a fresh chat for subsequent runs. Legacy stable-id sessions are adopted on first run so existing installs keep their context.
1 parent e9714d1 commit c39c765

5 files changed

Lines changed: 464 additions & 131 deletions

File tree

docs/cron.md

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,8 @@ prompt definition.
8484
| `model` | string | no | Override model (default: `agent.cron_model`) |
8585
| `target` | string | no | Delivery channel (default: `telegram`) |
8686
| `session_mode` | string | no | `isolated` (new session per run), `persistent` (reuse context), or `main` |
87-
| `context_rotate_hours` | int | no | Hours before persistent context resets (default: 24, 0 = never) |
87+
| `context_rotate_hours` | int | no | Hours before a persistent job rotates to a fresh chat (default: 24, 0 = never). The old chat is preserved |
88+
| `context_rotate_at` | string | no | Time of day to rotate (e.g. `"04:00"`, in the configured timezone). Overrides the hours-based rotation |
8889
| `reminder_mode` | bool | no | Persistent only: send short reminder instead of full prompt on subsequent runs (default: false) |
8990
| `catchup` | bool | no | Fire once on startup if the job missed a run while the server was down (default: true) |
9091
| `enabled` | bool | no | Whether the job is active (default: true) |
@@ -250,11 +251,16 @@ Each run creates a fresh session (`cron:{job_id}:{timestamp}`). The agent has no
250251

251252
### Persistent
252253

253-
Jobs with `session_mode: persistent` maintain SDK conversation context across runs:
254+
Jobs with `session_mode: persistent` maintain SDK conversation context across runs. Each job owns a **generation chat** — one session that is reused run after run until rotation:
254255

255-
- **First trigger**: Creates a fresh session (`cron:{job_id}`) and runs the prompt.
256-
- **Subsequent triggers**: Resumes the same SDK session and sends the prompt as a new message. The agent sees all prior runs in-context.
257-
- **Context rotation**: Every `context_rotate_hours` (default: 24), the context is reset. Old messages remain in the database and are searchable via memU, but the agent starts with a clean slate.
256+
- **First trigger**: Creates a fresh generation session (`cron:{job_id}:{timestamp}`) and runs the prompt.
257+
- **Subsequent triggers**: Resumes the same SDK session and sends the prompt as a new message. The agent sees all prior runs of this generation in-context.
258+
- **Context rotation**: Every `context_rotate_hours` (default: 24) — or daily at `context_rotate_at` — the current chat is **retired and a brand-new chat is started**. The old chat is preserved exactly as it was: it keeps its full message history, stays browsable (and resumable) in the UI like any other session, and ages out via the normal session archival policy. Retiring also indexes the old context into memU, cancels the old session's pending wakeups (so a retired thread can't resurrect itself alongside the new one), and retitles it with its end date (`Cron: {job_id} (until 2026-07-05)`).
259+
260+
> **Upgrade note.** Installs that predate generation chats used the stable id
261+
> `cron:{job_id}`. That session is adopted as the current generation on the
262+
> first run after upgrading — its SDK context carries over seamlessly — and
263+
> moves to the generation scheme on its next rotation.
258264

259265
This is useful for jobs that benefit from accumulated context:
260266
- Monitoring jobs that track changes over time
@@ -263,6 +269,10 @@ This is useful for jobs that benefit from accumulated context:
263269

264270
Between runs, the SDK client subprocess is freed (no resource leak). On the next trigger, the SDK resumes the session from its stored state.
265271

272+
Rotation can also be forced from the Cron page ("Rotate") or via
273+
`POST /api/cron/jobs/{job_id}/rotate` — same behavior: the current chat is
274+
preserved and the next run starts in a fresh one.
275+
266276
#### Reminder Mode
267277

268278
Persistent jobs with `reminder_mode: true` avoid resending the full prompt on every trigger. Instead:

nerve/agent/engine.py

Lines changed: 23 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3484,11 +3484,12 @@ async def _teardown_oneshot_client(
34843484
skips live-background-task sessions for the same reason).
34853485
34863486
``keepalive_if_bg`` MUST be False for runs whose ``session_id`` is
3487-
reused across runs (``run_persistent_cron``'s stable ``cron:{job_id}``):
3488-
parking such a client would let the NEXT scheduled run reuse the same
3489-
client/conversation while the prior run's background task is still in
3490-
flight, interleaving the two. Keep-alive is only safe for the
3491-
unique-per-run isolated paths (``run_cron`` / ``run_hook``).
3487+
reused across runs (``run_persistent_cron``'s generation session,
3488+
reused until context rotation): parking such a client would let the
3489+
NEXT scheduled run reuse the same client/conversation while the
3490+
prior run's background task is still in flight, interleaving the
3491+
two. Keep-alive is only safe for the unique-per-run isolated paths
3492+
(``run_cron`` / ``run_hook``).
34923493
"""
34933494
# Optimistic check: a task that settles between the watcher's last drain
34943495
# and here still reads as live, parking a client whose work is actually
@@ -3540,19 +3541,24 @@ async def run_persistent_cron(
35403541
job_id: str,
35413542
prompt: str,
35423543
model: str | None = None,
3544+
session_id: str | None = None,
35433545
) -> str:
35443546
"""Run a persistent cron job that maintains context across runs.
35453547
3546-
Uses a stable session_id (cron:{job_id}) so the SDK resumes
3547-
conversation context on subsequent triggers. The client is discarded
3548-
after each run to free the subprocess (sdk_session_id is preserved for
3549-
the next resume). Unlike the isolated one-shot paths it does NOT keep
3550-
the client alive for a live background task: the stable session is
3551-
reused by the next run, which would collide with the parked task — so a
3552-
persistent-cron background task that outlives its run is not resumed
3553-
(use an isolated cron for long background work).
3548+
The caller (CronService) resolves which generation chat session the
3549+
job currently owns and passes it as ``session_id`` — reusing it run
3550+
after run so the SDK resumes conversation context, and minting a
3551+
fresh session on context rotation (the old chat is preserved). When
3552+
no ``session_id`` is given, falls back to the legacy stable id
3553+
``cron:{job_id}``. The client is discarded after each run to free
3554+
the subprocess (sdk_session_id is preserved for the next resume).
3555+
Unlike the isolated one-shot paths it does NOT keep the client alive
3556+
for a live background task: the session is reused by the next run,
3557+
which would collide with the parked task — so a persistent-cron
3558+
background task that outlives its run is not resumed (use an
3559+
isolated cron for long background work).
35543560
"""
3555-
session_id = f"cron:{job_id}"
3561+
session_id = session_id or f"cron:{job_id}"
35563562
await self.sessions.get_or_create(
35573563
session_id, title=f"Cron: {job_id}", source="cron",
35583564
)
@@ -3564,9 +3570,9 @@ async def run_persistent_cron(
35643570
model=model or self.config.agent.cron_model,
35653571
)
35663572
finally:
3567-
# Stable session_id is reused by the next run, which would collide
3568-
# with a parked background task — so persistent crons always discard
3569-
# (no keep-alive). See _teardown_oneshot_client.
3573+
# The session is reused by the next run (until rotation), which
3574+
# would collide with a parked background task — so persistent
3575+
# crons always discard (no keep-alive). See _teardown_oneshot_client.
35703576
await self._teardown_oneshot_client(session_id, keepalive_if_bg=False)
35713577

35723578
async def run_hook(

0 commit comments

Comments
 (0)