Jobs that remember where they left off
A nightly agent job that summarizes the same 200 issues every night has no memory of last night. Here's why scheduled work needs state, and how Minion packages keep cursors and SQLite tables between runs.
The first nightly job you have an agent write usually works perfectly on Monday. On Tuesday it summarizes the same 200 GitHub issues again, plus three new ones. On Wednesday it does it a third time, and you stop reading the digest.
Nothing is broken, exactly. The code does what it was told: fetch open issues, summarize, email. It just has no idea it ran before. That's the gap between a script that runs on a schedule and an idempotent cron job, one you can run twice, or after a crash, and get the same result as running it once. The difference is almost always state.
What goes wrong when a scheduled job has no memory
Scheduled agent work fails in a few recognizable ways, and none of them throw an error.
- It re-processes everything. No cursor, so every run starts from the beginning. The 200 issues get summarized every night, and the three new ones are buried under the 197 you already saw.
- It sends the same thing twice. A job times out after the email went out but before it finished. The scheduler, or you, runs it again. Now your team has two copies of the digest and quietly learns to ignore it.
- It loses its place after a crash. A sweep over 5,000 records dies at record 3,100. The next run either starts at zero and redoes the work, or skips ahead and misses the gap. You don't know which until someone asks why a customer never got their email.
The usual fix in a chat-only setup is to ask the agent to "remember where it got to." But the agent that wrote the job isn't the thing running it at 3am. The job runs on its own. Whatever it needs to know about last night has to live somewhere the job can read.
How to persist state between cron runs
In Minion, recurring work lives in a saved package, and every saved package gets two places to keep state ( storage has the overview). Both come from minion:runtime.
The first is a key/value store, packageStorage(). It's for small things: a cursor, a checkpoint, a timestamp, a cached token. Values are JSON, up to 256 KB each, under keys of up to 256 characters.
Here's the issue digest fixed with one cursor:
import { packageStorage } from 'minion:runtime'
export default async function () {
const store = packageStorage()
const lastIssueId = (await store.get('lastIssueId')) ?? 0
const issues = await fetchIssuesAfter(lastIssueId)
if (issues.length === 0) return { sent: false }
await minion.email.send({ subject: 'New issues', text: summarize(issues) })
await store.set('lastIssueId', issues.at(-1).id)
return { sent: true, count: issues.length }
} Tuesday's run now reads the last id Monday saved, fetches only what came after it, and sends three issues instead of 203. If nothing new came in, it sends nothing. (fetchIssuesAfter and summarize are your own code; the storage and email calls are Minion's.)
The order matters, and I'll come back to it.
The second place is a SQLite database, through sql(). A key/value store is fine for "where did I get to." It gets awkward when you want "which of these have I already handled," or "what did I see in the last day." That's rows, and rows want a table:
import { sql } from 'minion:runtime'
export default async function () {
await sql(`create table if not exists issues (
id integer primary key, title text not null,
seen_at text default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')))`)
for (const issue of await fetchOpenIssues()) {
await sql('insert into issues (id, title) values ($1, $2) on conflict (id) do nothing',
[issue.id, issue.title])
}
const { rows } = await sql(
"select id, title from issues where seen_at > strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-1 day')")
return rows
} on conflict (id) do nothing is the whole trick. Run it once or five times and the table ends up the same. Issues you've seen before keep their original seen_at, so "new in the last day" means new, not "fetched again today."
The rules for sql() in a package
I'd rather you hit these in a blog post than in a failed run. They're all in the package docs .
- One SQLite statement per call. Params are
$1,$2(or?), up to 100 of them: strings, numbers, booleans (stored as 1/0) or null. - Each call has a 5 second limit and returns at most 1,000 rows and 5 MB. A single query can be up to 64 KB.
- Each call is its own transaction. There's no
BEGINorCOMMIT. A statement that fails or hits a limit is rolled back, but two statements aren't one unit. - The database belongs to that package alone. Sibling packages, app tables and other organizations can't reach it. Delete the package and its database goes with it.
- Its size counts against your plan's storage limit, shared with key/value storage across the organization.
sql()only works inside saved packages, meaning exports and jobs. From ad hoc code the same call isminion.storage.sql({ packageId, query, params }), and owners and admins can read a package's key/value storage from there while debugging.
The no-transactions rule is the one that shapes how you write jobs. You can't wrap "send the email" and "record that I sent it" in one atomic step. So you pick which way to fail.
How to write an idempotent cron job for an agent
Back to the order in the digest. It sends, then saves the cursor. If the job dies between those two lines, the next run sends the same issues again. One duplicate email, once, after a crash. Flip the order and a crash between the lines means those issues are never sent at all.
For a digest I'll take the occasional duplicate over silent loss. For something like charging a card, I'd do the opposite: write a row saying "about to charge invoice 991," charge it, then mark it done. On the next run, anything left in "about to" is the thing to check by hand. Neither is magic. You're just deciding where the gap goes and making it small.
A few habits that make an idempotent cron job much easier to get right:
- Key on something stable. Issue ids, invoice numbers, message ids. Not "today's date," which changes if the job runs late.
- Make writes repeatable.
insert ... on conflict do nothing, orseta cursor to a value rather than incrementing it. - Checkpoint long sweeps. Save the cursor every few hundred records, not only at the end. A job gets 4.5 minutes, so a big backfill will span runs anyway.
- Return what you did. A job that returns
{ sent: true, count: 3 }leaves a trail in run history, and a quiet run that returns{ sent: false }tells you it checked.
When a job does fail, it shows up in run history with the error. Fix it, run it again with minion.jobs.runNow({ id }), and the cursor means it picks up where it stopped instead of starting over.
Why the state lives with the package
You could keep a cursor in a file or a spare database somewhere. That works for a while. The trouble is it drifts away from the code. Someone deletes the job and the table sits there for a year. Someone copies the job and both copies fight over one cursor.
In Minion the state is part of the package. The schedule is in its package.json, the code is versioned, and the storage and database belong to it and nothing else. Any agent you connect can read the package, see what it does, and help fix it. That's most of what I mean by turning agent work into software you own : a job with its own memory, not a script someone set up once and nobody dares touch.
Try it
Pick a job you already run on a schedule, or one you've been meaning to write. Ask your agent to save it as a Minion package with a daily job, keep its cursor in packageStorage(), and run it twice with minion.jobs.runNow. The second run should do nothing. If it does something, you've found your bug before your team did.
Create an account , or start with first steps .