The other night, several tasks fired at exactly 20:00, fought over the free space in a shared /tmp and the rate-limit budget, and a later job ran out of breath halfway through a clone. As an indie developer I let Cowork scheduled tasks run the automated posting for several sites, and by adding one task at a time I had quietly drifted into a state where a handful of them all fired at the same minute at night.
The tricky part is that each task looks perfectly healthy. Every cron is doing exactly what it was told. The problem lives in a property you can only see from above: how many jobs start in a single minute once you line them all up. This piece pulls that overhead view out of the cron expressions mechanically instead of relying on intuition, and shaves the peak while leaving the revenue-critical premium slots completely untouched.
Expand cron into hours and minutes, then count same-minute fires
The first thing we need is a full expansion of "which task fires at which minute of the day." A Cowork scheduled task is described by a five-field cron (minute hour day month weekday), so we pull the fire minute (a 0–1439 index) out of it.
Real-world crons are almost always plain numbers or comma lists (for example 0 9,20 * * *), so starting there cuts down on parsing mistakes. Adding ranges and steps is fine once this foundation works correctly.
type Task = { id: string; cron: string; durationMin: number; protected: boolean };
// Expand a field like "0,30" or "9,20" into an array of numbers
function parseField(field: string, min: number, max: number): number[] {
if (field === "*") {
const all: number[] = [];
for (let v = min; v <= max; v++) all.push(v);
return all;
}
return field.split(",").map((s) => {
const n = Number(s);
if (!Number.isInteger(n) || n < min || n > max) {
throw new Error(`Invalid cron field value: "${s}"`);
}
return n;
});
}
// Return the day's fire times (minute index 0..1439). We ignore day/month/weekday
// here on the assumption of "runs daily", and look at daily concurrency using only
// hour and minute (most generation tasks run daily or on fixed weekdays).
function fireMinutes(cron: string): number[] {
const parts = cron.trim().split(/\s+/);
if (parts.length !== 5) {
throw new Error(`cron does not have five fields: "${cron}"`);
}
const [mField, hField] = parts;
const minutes = parseField(mField, 0, 59);
const hours = parseField(hField, 0, 23);
const out: number[] = [];
for (const h of hours) for (const m of minutes) out.push(h * 60 + m);
return [...new Set(out)].sort((a, b) => a - b);
}With each task's fire minutes in hand, we build a map of "how many fire in the same minute."
// fire minute -> the task IDs that start in that minute
function collisionMap(tasks: Task[]): Map<number, string[]> {
const map = new Map<number, string[]>();
for (const t of tasks) {
for (const fm of fireMinutes(t.cron)) {
const list = map.get(fm) ?? [];
list.push(t.id);
map.set(fm, list);
}
}
return map;
}
function toHHMM(minute: number): string {
const h = String(Math.floor(minute / 60)).padStart(2, "0");
const m = String(minute % 60).padStart(2, "0");
return `${h}:${m}`;
}Pick out the minutes in collisionMap whose value length is 2 or more, and you get a list of "tasks that fire in the same minute." My first mistake here was assuming this "same-minute count" was the concurrency itself. It is only the entrance.
"Overlapping starts" and "how many run at once" are not the same
Even when starts land in different minutes, tasks with long run times overlap while running. A job that starts at 19:45 and takes 20 minutes runs alongside another that starts at 20:00, together from 20:00 to 20:05. So what we really want is not "collision of starts" but "how many are running at a given instant" — the true concurrency.
We lay each run out as an interval [fireMinute, fireMinute + durationMin) and find the maximum concurrency with a sweep line. There is one easy-to-miss trap. When an "end" and a "start" happen in the same minute, counting them as overlapping overstates concurrency — you misdetect two merely adjacent jobs as concurrent.
type Interval = { start: number; end: number; id: string };
function runIntervals(tasks: Task[]): Interval[] {
const intervals: Interval[] = [];
for (const t of tasks) {
for (const fm of fireMinutes(t.cron)) {
intervals.push({ start: fm, end: fm + t.durationMin, id: t.id });
}
}
return intervals;
}
function peakConcurrency(intervals: Interval[]): { peak: number; atMinute: number } {
const events: { minute: number; delta: number }[] = [];
for (const iv of intervals) {
events.push({ minute: iv.start, delta: +1 });
events.push({ minute: iv.end, delta: -1 });
}
// In the same minute, process the end (-1) before the start (+1).
// Otherwise a job "ending at 20:05" and one "starting at 20:05" get
// counted as running together.
events.sort((a, b) => a.minute - b.minute || a.delta - b.delta);
let cur = 0;
let peak = 0;
let atMinute = 0;
for (const e of events) {
cur += e.delta;
if (cur > peak) {
peak = cur;
atMinute = e.minute;
}
}
return { peak, atMinute };
}The key is that sorting delta ascending puts -1 ahead of +1. This lines up with treating intervals as half-open [start, end), so adjacent jobs are not counted as concurrent. Get this one ordering wrong and the peak looks one or two jobs higher than reality, which pushes you to move tasks you didn't need to touch.