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").
The wire is not a constant
fire.kind: "session" opens a real session over the host's public wire. That wire was rebuilt in 0.1.2-alpha.1: dotted endpoints without auth (POST /api/session.prompt) became slash endpoints with a cookie, a double-wrapped payload, and a client-minted requestId. So this package does not read a version number — it probes each host and speaks whatever that host speaks, then records which one in the ledger.
That indirection exists because of a trap worth stating on its own:
After an in-place source upgrade, an already-running host keeps executing the old code from memory, while the directory name,
package.jsonandgit describeall report the new version.
Measured on one machine, same day: a host started before the upgrade answered unauthenticated agentPreset.list with ok; a host started after it answered 401 to the identical call. Nothing on disk distinguishes them — only their replies do. Compare ps -o lstart against the checkout's upgrade time; do not trust the directory name.
Two more facts the client encodes, both measured:
- The double-wrap key is per endpoint.
session/createandsession/prompttake{args:{request:{…}}};session/listtakes{args:{_request:{…}}}. The gateway'sgateway/arguments-invaliderror names the missing and unexpected fields verbatim, so the client reads the error rather than guessing. - The auth cookie outlives the host. The launch token is per-process, printed once to stdout, never persisted — but the cookie it buys is signed with a stored secret and bound to
host:port, so it keeps working across restarts for 30 days. Exchange once; unattended work then survives every restart. - Mid-session questions no longer need a human. An unattended session stalls the moment the agent calls ask-user-question. The event plane is one WebSocket carrier (
/api/remote.mux, behind the same cookie gate; Node ≥ 22's built-in WebSocket takes a non-standard{headers}init, so this stays dependency-free): open a$eventsstream — the gateway insists on a literal empty{args:{}}and says so verbatim if you send anything else — readready.clientId, then answer anyuser-questions/requestwaterfall frame whoseagentIdequals your session id viaPOST /api/$events/result. Measured end to end on 0.1.2-alpha.2: the agent asked,attendQuestions()picked a label, the agent resumed with the chosen answer.node lib/cli.js attend <port> <sessionId>does this from a shell script.
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