dsh-cron
Scheduled work for DeepSeek Harness that survives session end, host restart and machine sleep — it schedules an outcome (completion window + effect check + catch-up), not a moment.
- Stars
- 1
- Language
- TypeScript
- Created
- Aug 26, 2026
- Updated
- Aug 26, 2026
Introduction
dsh-cron
English | 中文
Scheduled work for DeepSeek Harness that survives session end, host restart, and machine sleep — because it schedules an outcome, not a moment.
Why this exists
DSH ships a schedule family, but it is deliberately an in-session reminder. Its delivery boundary is written into the type:
/** Fixed v1 delivery boundary: the original session must be live. */
type ScheduleDeliveryMode = 'session-local'
with the note that "the original Session must be live: no external notification channel or cold-session scheduler exists", and a package README that says it "intentionally exposes no Schedule service or mutable database".
That is a scope decision, not a defect. In DSH the unit that does work is a session with a live agent, and nothing outside a live session is allowed to hold state that drives work. A cron needs a process that outlives every session — a different thing. This plugin is that thing: it lives in the host process, survives across sessions, and calls sessions up from the outside when work is due.
What makes it different from crontab / launchd
It schedules by outcome, not by clock time.
Triggers were never the scarce part. Here is a real failure log from one small daily pipeline:
| When | Which link broke | What they had in common |
|---|---|---|
| Day 1–2 | one letter wrong in a path inside the script | silent |
| Day 3–4 | credential expired; the session opened, the work never happened | silent |
| Day 5 | never fired, no reason recoverable from system logs | silent |
Three breaks, three different links, and not one of them was the trigger. Adding a more accurate trigger just buys a fourth silent failure mode.
So the unit here is a completion window:
cron time ──────────── completion window ──────────→ window closes
│ │
├─ ask the check: did the thing actually happen? │
│ yes → done │
│ no → fire once, ask again after retryEvery ──────┤
│ ↓
└──────────────────────────────────────→ still not done = MISS, recorded loudly
The guarantee is "it gets done today", not "it fires at 09:00". A machine that sleeps and a host that restarts cannot honour the second one, and promising it produces exactly the silent misses above.
Two properties fall out for free:
- Catch-up. A window missed while the host was down is picked up on the first tick after it comes back. (Plain
cronskips a sleep-missed run and never reruns it.) - Someone else's work counts. If a human already did the thing by hand, the check says so and nothing fires. Scheduling by outcome means you want the thing to have happened, not to have fired.
Tools
| Tool | What it does |
|---|---|
cron_add | Schedule a task (with a check, or it is not really scheduled) |
cron_list | Current tasks and their window state |
cron_status | Read the append-only run ledger — the answer to "did it run today?", as evidence, not a summary |
cron_remove | Remove / pause / resume |
cron_run_now | Run one evaluation pass immediately, to test a task without waiting for its window |
Example — a daily report that must go out within six hours of 09:00:
{
"id": "daily-digest",
"cron": "0 9 * * *",
"tz": "Asia/Shanghai",
"window": "6h",
"retryEvery": "30m",
"check": { "kind": "command",
"run": ["/bin/sh", "-c", "grep -q \"$(date +%F)\" ~/reports/sent.log"] },
"fire": { "kind": "session", "preset": "digest-bot",
"prompt": "Produce today's report and send it", "cwd": "/home/you/reports" }
}
fire is either session (opens a real unattended session, prefixed with a discipline header: nobody is there to answer questions, a failed attempt means try another way, finish the job) or command (deterministic work needs no model).
check is either command (exit 0 means done) or http (2xx, optional contains). No model is involved in deciding whether the work happened — a session reporting "sent!" is the least trustworthy evidence on the chain.
What happens without a check
It degrades to plain cron: one blind shot per window, and nobody ever learns whether it worked. The cron_add receipt, every cron_list row, and every ledger line say so explicitly. Degrading is allowed; degrading silently is not.
Install
npm install && npm run link:dsh && npm run build
Add the dependency to your DSH profile's package.json, then insert the plugin in cordis.patch.yml:
- insert:
- id: dsh-cron
name: '@dsh-external/dsh-cron'
Config (all optional): root (data directory, default $DSH_HOME/cron), tickMs (evaluation interval, default 60s), wirePort (host port used to open sessions; read from the host's web server by default).
State lives in $DSH_HOME/cron/: tasks.json (definitions and window state, written atomically) and runs.jsonl (append-only ledger, never rewritten — a state file that gets overwritten cannot answer "what actually happened today").
Honest limits
- The plugin lives inside the host process. If the host is down, so is this. "Who wakes the waker" moves up one level rather than disappearing; starting the host at boot is still launchd's job.
- The quality of this package equals the quality of your checks. A loose check (say,
test -fon a file that is always there) will cheerfully report that everything is fine. - The ledger is append-only and does not rotate itself.
- Single machine, single host. No cross-machine coordination, no distributed lock.
Development
npm test # builds, then runs the suite
The decision core (src/schedule.ts) is pure — no clock, no disk, no processes — and every one of its tests is written against a real-world break: host down through the cron time, machine waking up, a credential expiring mid-window, a human doing the work by hand, a window closing unfinished.
MIT