●2.1.281 — Claude Code reached 2.1.281 on September 23. The gateway now understands the newer Claude Desktop policy keys, and Bedrock upstreams gain assume_role and a guardrail setting●10/07 — The old spellings of the Claude Desktop and Cowork managed config keys stop being accepted at 12:00 PT on October 7, thirteen days from now●529 — A report describes background subagents ending mid-task on a transient 529, leaving the parent to piece together what actually survived●NEW — Bracketing PDF input tokens by page count before you send the file●RORK — Rork added Claude Opus 5.5 to its model menu on September 22, so the same model landed in several tools within one week●UNIT — When you hand off a long job, committing after each unit of work means a crash costs you one step, not the whole run●2.1.281 — Claude Code reached 2.1.281 on September 23. The gateway now understands the newer Claude Desktop policy keys, and Bedrock upstreams gain assume_role and a guardrail setting●10/07 — The old spellings of the Claude Desktop and Cowork managed config keys stop being accepted at 12:00 PT on October 7, thirteen days from now●529 — A report describes background subagents ending mid-task on a transient 529, leaving the parent to piece together what actually survived●NEW — Bracketing PDF input tokens by page count before you send the file●RORK — Rork added Claude Opus 5.5 to its model menu on September 22, so the same model landed in several tools within one week●UNIT — When you hand off a long job, committing after each unit of work means a crash costs you one step, not the whole run
Will Your Cowork Scheduled Task Still Run Next Saturday?
A definition that runs once and stops, two fire times packed into one task, a weekday that quietly emptied after an edit. Cowork schedules can go missing without leaving a single log line. Here is a working audit that compares what you defined with what you meant.
One Saturday morning, a single output that should have been waiting for me simply was not there. No error notification. The task list showed a neat column of green "enabled" toggles.
The cause was a small edit I had made myself earlier that week. I had two tasks taking turns on weekend work for two sites, and I moved one of them onto the same day as the other because it looked tidier. After that, nothing covered Sunday at all.
Nothing was broken. The schedule was simply missing a piece. As an indie developer who keeps adding unattended jobs, this is the kind of gap that costs me the most time.
You cannot find a task that never ran by reading run logs
The first thing I'd like to say plainly: this kind of gap does not show up in run logs.
A failed task leaves a log. A late task leaves some trace. A task that never fired writes nothing at all. The more carefully you read the logs, the more convinced you become that everything is fine.
This piece is about the step before that. Read the task definitions themselves, and find where they disagree with your intent before anything runs.
Three ways my schedules went missing
Looking back over the last few months, every gap I hit fell into one of three shapes.
Shape
How it looked in the list
What actually happened
One-off definition
Enabled, next run shown
A task I meant to repeat showed "One-time:" in its schedule field, ran once, and then disabled itself
Two fire times in one task
Enabled, cron looked correct
I wrote 30 4,16 * * * to classify wallpaper categories morning and evening; in my environment only one of the two fired
A weekday emptied by an edit
Everything enabled
I moved one of two alternating tasks onto the other's day, and nothing was left to cover Sunday
The wallpaper category job now runs as two separate tasks, one for the morning slot and one for the evening slot. Giving each slot its own name means I can see at a glance when only one of them is moving.
What the three have in common is that the list looked healthy every time. The toggles were on. The cron strings had no typos. What was missing was the information about what I expected — and that lives nowhere in the task definitions.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦You will be able to surface one-off definitions, double-slot crons, and uncovered weekdays in a few minutes, using a ledger and a short script instead of staring at the task list
✦You will be able to check which weekdays a cron edit is about to remove before you save it, so silent gaps are stopped at edit time rather than discovered on a missing Saturday
✦You will be able to leave a reason next to every disabled task, so that months later you can decide whether it is safe to turn back on without guessing
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
So I keep two small ledgers: one that copies the task list, and one that says which weekdays each group of tasks should cover. Copying is manual, but even with a few dozen tasks it takes minutes.
The first ledger mirrors the definitions. stream is a name you choose for a group of tasks sharing one job.
A stream with recurring set to false is one that should run exactly once, like a follow-up measurement. Without that flag, legitimate one-offs would drown in warnings.
The example above packs all three shapes into one sheet for illustration. A real ledger usually has one or two problems, not five.
A script that compares definitions with intent
This reads both ledgers and reports four kinds of disagreement. It uses only the standard library, so it runs as-is in the Cowork sandbox or on a local Mac.
#!/usr/bin/env python3"""Read a scheduled-task ledger (TSV) and catch accidents that are visible in the definitions alone.Detects: ONE-TIME : a one-off definition inside a stream that is meant to recur MULTI : a single cron that fires more than once a day (split it) GAP : weekdays a stream should cover that no enabled task actually fires on DISABLED : a disabled task with no reason written in the ledger"""import csvimport sysfrom datetime import datetime, timedeltaDOW_NAMES = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]def expand_field(expr, lo, hi, is_dow=False): """Expand one cron field into a set of ints (*, a-b, a,b, */n, a-b/n).""" values = set() for part in expr.split(","): step = 1 if "/" in part: part, step_s = part.split("/", 1) step = int(step_s) if part == "*": start, end = lo, hi elif "-" in part: a, b = part.split("-", 1) start, end = int(a), int(b) else: start = end = int(part) values.update(range(start, end + 1, step)) if is_dow: # In cron, both 0 and 7 mean Sunday. Normalize to Python weekday() (Mon=0) values = {6 if v in (0, 7) else v - 1 for v in values} return valuesdef parse_cron(expr): fields = expr.split() if len(fields) != 5: raise ValueError(f"not a 5-field cron: {expr!r}") minute, hour, dom, month, dow = fields return { "minute": expand_field(minute, 0, 59), "hour": expand_field(hour, 0, 23), "dom": expand_field(dom, 1, 31), "month": expand_field(month, 1, 12), "dow": expand_field(dow, 0, 7, is_dow=True), "dom_any": dom == "*", "dow_any": dow == "*", }def day_matches(c, day): if day.month not in c["month"]: return False dom_ok = day.day in c["dom"] dow_ok = day.weekday() in c["dow"] # Standard cron: if both day-of-month and day-of-week are set, EITHER one matches if c["dom_any"] or c["dow_any"]: return dom_ok and dow_ok return dom_ok or dow_okdef fires_on(c, day): """Fire times on that day, in minutes after midnight.""" if not day_matches(c, day): return [] return sorted(h * 60 + m for h in c["hour"] for m in c["minute"])def load_ledger(path): with open(path, newline="", encoding="utf-8") as f: rows = list(csv.DictReader(f, delimiter="\t")) for r in rows: r["enabled"] = r["enabled"].strip().lower() in ("1", "true", "yes", "on") return rowsdef coverage(rows, start, days): """For each stream, the set of weekdays covered by enabled recurring tasks.""" cov = {} for r in rows: cov.setdefault(r["stream"], set()) if r["kind"] != "cron" or not r["enabled"]: continue c = parse_cron(r["schedule"]) for i in range(days): day = start + timedelta(days=i) if fires_on(c, day): cov[r["stream"]].add(day.weekday()) return covdef expected_days(spec): spec = spec.strip().lower() if spec == "daily": return set(range(7)) return {DOW_NAMES.index(s.strip()) for s in spec.split(",") if s.strip()}def audit(rows, streams, start, days=14): findings = [] for r in rows: name, stream = r["name"], r["stream"] recurring = streams.get(stream, {}).get("recurring", True) if r["kind"] == "one-time" and recurring: findings.append(("ONE-TIME", name, f"stream {stream} is meant to recur, but this is a one-off definition ({r['schedule']})")) if r["kind"] == "cron": c = parse_cron(r["schedule"]) per_day = len(c["hour"]) * len(c["minute"]) if per_day > 1: findings.append(("MULTI", name, f"one task holds {per_day} fires per day ({r['schedule']}); give each slot its own task")) if not r["enabled"] and not r.get("note", "").strip(): findings.append(("DISABLED", name, "disabled, but the ledger has no reason for stopping it")) cov = coverage(rows, start, days) for stream, spec in streams.items(): if not spec.get("recurring", True): continue missing = expected_days(spec["days"]) - cov.get(stream, set()) if missing: names = ",".join(DOW_NAMES[d] for d in sorted(missing)) findings.append(("GAP", stream, f"no enabled task covers {names}, which this stream is expected to run on")) return findingsdef load_streams(path): streams = {} with open(path, newline="", encoding="utf-8") as f: for r in csv.DictReader(f, delimiter="\t"): streams[r["stream"]] = { "days": r["days"], "recurring": r["recurring"].strip().lower() in ("1", "true", "yes"), } return streamsif __name__ == "__main__": if len(sys.argv) < 3: sys.exit("usage: schedule_audit.py tasks.tsv streams.tsv") rows = load_ledger(sys.argv[1]) streams = load_streams(sys.argv[2]) today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) findings = audit(rows, streams, today) for kind, target, msg in findings: print(f"[{kind:8}] {target}: {msg}") print(f"-- {len(findings)} finding(s)") sys.exit(1 if findings else 0)
Run against the two ledgers above, it reports five findings.
[MULTI ] wallpaper-category: one task holds 2 fires per day (30 4,16 * * *); give each slot its own task[ONE-TIME] monthly-check: stream monthly-check is meant to recur, but this is a one-off definition (2026-10-01T09:00)[DISABLED] backup-fri: disabled, but the ledger has no reason for stopping it[GAP ] monthly-check: no enabled task covers mon,tue,wed,thu,fri,sat,sun, which this stream is expected to run on[GAP ] weekend: no enabled task covers sun, which this stream is expected to run on-- 5 finding(s)
effect-eval is a one-off too, but its stream says recurring=false, so it stays quiet. monthly-check, on the other hand, appears under both ONE-TIME and GAP. A one-off definition still runs this week, so if you only looked at GAP-style coverage you could miss it on the wrong day. I overlap the two checks on purpose so the same gap gets caught from two angles.
The exit code is 1 if there is even one finding, so anything calling the script can branch on that value alone.
Why read definitions instead of logs
I hesitated over a few choices while writing this, so here is the reasoning.
Why MULTI says "split it" rather than "wrong".30 4,16 * * * is valid cron syntax, and a standard cron would fire twice a day. I never found out why only one fired in my environment. Rather than bet on something I cannot verify, I decided it is cheaper to keep one slot per task. Split tasks also tell you by name which slot went missing.
Why I never set day-of-month and day-of-week together. In standard cron, if both the third and fifth fields are set, the job fires on days matching either one. The script follows that rule, so 0 9 1 * 1 fires on October 1st and on every Monday in October. That runs against intuition — I first assumed it meant "the 1st, if it is a Monday." I have not verified whether Cowork's scheduler follows the standard rule, so I simply avoid writing both.
Why fourteen days of expansion. Biweekly or start-of-month definitions look like gaps if you only expand seven days. Two weeks is enough to get the weekday set right. If you have monthly streams, raise the days argument to around 35.
Why DISABLED demands a reason. A stopped task carries context that only the person who stopped it knows. The accident I fear most is me, three months later, having forgotten that context and switching it back on. One line in the note column hands that decision from past me to future me.
Before an edit, check only which weekdays disappear
Even running the audit weekly, the Saturday gap from the opening would have stayed open for a few days. What I really wanted was to stop it at the moment of the edit.
So I added a small tool that compares the ledger before and after a change and reports only the weekdays the change newly leaves empty.
#!/usr/bin/env python3"""Compare ledgers before and after a cron edit and show only the weekdays the edit newly leaves empty."""import sysfrom datetime import datetimefrom schedule_audit import DOW_NAMES, coverage, load_ledgerdef new_holes(before_rows, after_rows, start, days=14): before = coverage(before_rows, start, days) after = coverage(after_rows, start, days) holes = {} for stream, days_before in before.items(): lost = days_before - after.get(stream, set()) if lost: holes[stream] = sorted(lost) return holesif __name__ == "__main__": before_rows = load_ledger(sys.argv[1]) after_rows = load_ledger(sys.argv[2]) start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) holes = new_holes(before_rows, after_rows, start) if not holes: print("OK: this change leaves no weekday empty") sys.exit(0) for stream, lost in holes.items(): print(f"HOLE: {stream} loses {','.join(DOW_NAMES[d] for d in lost)}") sys.exit(1)
Here is the change that moves one of the Saturday/Sunday pair onto Saturday.
This tool never reads the intent ledger. It compares only "weekdays covered before" with "weekdays covered after." Even for a stream whose intent you forgot to write down, it still shows exactly what an edit takes away. The audit measures distance from how things should be; the guard measures distance from how things were a minute ago.
Moving tasks onto the same day is usually well-meant housekeeping. The edits that feel like tidying are the ones whose gaps you notice last. I try to remember that sentence every time I open a cron field.
Three small promises that keep the ledger honest
A ledger starts going stale the moment you finish copying it. I keep just three habits.
Whenever I create or delete a task, or change a cron, I fix the ledger row as part of the same piece of work
Before changing a cron, I run change_guard.py, and if it reports a HOLE, the change waits
Once a week, I check by eye that the task list and the ledger have the same number of rows, then run schedule_audit.py
The row count check is unglamorous, but a task missing from the ledger is outside every check. Counting is enough to close that blind spot.
A good first step is to copy your currently enabled tasks into a ledger and write one line per stream saying which weekdays it should run. You may well find something before you even run the script.
Share
Thank You for Reading
Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.