●MODEL — Claude Sonnet 5 becomes the default across all plans, with stronger planning, tool use, and autonomy●PRICE — Sonnet 5 launches at $2 input / $10 output per million tokens through August 31●MODEL — Sonnet 5 nears Opus 4.8 performance at a lower price for always-on agents●CODE — Claude Code adopts Sonnet 5 as default with a native 1M-token context window●CODE — Claude Code adds sandbox credential blocking and org-level model restrictions●CLOUD — Claude is generally available in Microsoft Foundry on Azure with Azure-native access●MODEL — Claude Sonnet 5 becomes the default across all plans, with stronger planning, tool use, and autonomy●PRICE — Sonnet 5 launches at $2 input / $10 output per million tokens through August 31●MODEL — Sonnet 5 nears Opus 4.8 performance at a lower price for always-on agents●CODE — Claude Code adopts Sonnet 5 as default with a native 1M-token context window●CODE — Claude Code adds sandbox credential blocking and org-level model restrictions●CLOUD — Claude is generally available in Microsoft Foundry on Azure with Azure-native access
How Many Tasks Fire in the Same Minute — Flattening Cowork Scheduled-Task Collisions from Cron
When Cowork scheduled tasks bunch up at the same time and fight over shared resources, you can expand every cron expression into fire times, count collisions and true concurrency, and shave the peak with a greedy offset that never moves your premium slots. With working code and measured before/after numbers.
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 numbersfunction 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 minutefunction 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.
✦
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
✦A TypeScript implementation that expands every task's cron into a day of fire times and detects both same-minute collisions and per-window concurrency
✦A sweep-line that separates 'overlapping starts' from 'how many run at once', including the ordering trick that stops adjacent runs being counted as concurrent
✦A greedy offset that keeps premium slots fixed and moves only the flexible tasks to cut the peak, with a measured before/after
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.
Visualize how many run per minute, window by window
Once you know the maximum, you want to see the congestion as a surface. Build a load array for all 1440 minutes of the day and pull out only the busy stretches into a table.
// Return a 1440-element array of how many runs occupy each minutefunction loadProfile(intervals: Interval[]): number[] { const load = new Array<number>(1440).fill(0); for (const iv of intervals) { const end = Math.min(iv.end, 1440); for (let m = iv.start; m < end; m++) load[m]++; } return load;}// Merge contiguous minutes whose load is at least the threshold into windowsfunction congestedWindows(load: number[], threshold: number) { const windows: { from: number; to: number; peak: number }[] = []; let m = 0; while (m < load.length) { if (load[m] >= threshold) { const from = m; let peak = 0; while (m < load.length && load[m] >= threshold) { peak = Math.max(peak, load[m]); m++; } windows.push({ from, to: m - 1, peak }); } else { m++; } } return windows;}
Call congestedWindows(load, 2) and the stretches where "two or more run at once" come out grouped. When I ran this against my own setup, it was instantly clear the peak was skewed to the evening, roughly 19:30–20:20. Staring at the crons by hand, I had never once noticed that skew.
Keep premium slots fixed, move only the flexible tasks
Here is the heart of the design decision. If you move tasks blindly to cut the peak, the premium article generation slots drift too, and they stop publishing in the window you aimed for. In the Dolice setup I draw an explicit line with a protected flag: premium slots are protected, and only free generation and supporting tasks are treated as movable.
Greedily, we nudge each movable task toward "the emptiest nearby minute," one at a time. The trick is to lay down the fixed tasks' load first, then place the movable tasks into low-load positions in order.
function staggerOffsets(tasks: Task[], windowMin = 90): Map<string, number> { const chosen = new Map<string, number>(); const fixed = tasks.filter((t) => t.protected); const movable = tasks.filter((t) => !t.protected); // Lay down the load of the fixed tasks (premium slots, etc.) first const base = loadProfile(runIntervals(fixed)); for (const t of movable) { const start = fireMinutes(t.cron)[0]; // assume one fire per day let bestOffset = 0; let bestPeak = Infinity; for (let off = -windowMin; off <= windowMin; off++) { const s = start + off; if (s < 0 || s + t.durationMin > 1440) continue; // Max load across the minutes this candidate position would occupy let localPeak = 0; for (let m = s; m < s + t.durationMin; m++) { localPeak = Math.max(localPeak, base[m]); } // On a tie, prefer the offset closest to the original time const better = localPeak < bestPeak || (localPeak === bestPeak && Math.abs(off) < Math.abs(bestOffset)); if (better) { bestPeak = localPeak; bestOffset = off; } } // Commit the chosen position into the load, then move to the next task const finalStart = start + bestOffset; for (let m = finalStart; m < finalStart + t.durationMin; m++) base[m]++; chosen.set(t.id, bestOffset); console.log( `${t.id}: ${toHHMM(start)} -> ${toHHMM(finalStart)} ` + `(${bestOffset >= 0 ? "+" : ""}${bestOffset}min, peak load ${bestPeak})` ); } return chosen;}
windowMin is how far you allow a task to move from its original time. I keep it at 90 minutes. Widen it and side effects creep in — an article you wanted out before the morning commute slips into the afternoon. The tie-break that pulls toward the original time is there for the same reason: don't move things more than you have to.
The before / after I actually applied
Against a setup with 2 protected tasks (premium slots) and 4 movable ones, applying the greedy offset gave the following. The evening-skewed starts were flattened with windowMin = 90.
Task
Kind
Before
After
Shift
premium-batch-1
Protected
19:30
19:30
0 min
premium-batch-2
Protected
20:00
20:00
0 min
weekend-content
Movable
20:00
18:35
-85 min
brushup-evening
Movable
19:30
21:00
+90 min
integration
Movable
20:00
21:25
+85 min
troubleshooting
Movable
19:45
18:15
-90 min
Assuming a 15-minute run time per task, concurrency changed like this.
Metric
Before
After
Max concurrency
4 jobs (at 20:00)
2 jobs
Window with 2+ overlapping
19:30–20:20 (~50 min)
Essentially gone
Premium slot fire times
19:30 / 20:00
19:30 / 20:00 (unchanged)
Max concurrency dropped from 4 jobs to 2, and the premium slots did not move by a single minute. The 20:00 mountain where jobs fought over shared resources is gone, and clones stopped running out of breath afterward. Once you can confirm "the mountain is gone" as a number, the unease from the days of nudging times by gut feel disappears.
Why greedy instead of exhaustive search
Placing tasks is, strictly, a combinatorial optimization — minimizing the peak of interval scheduling — and the closer you get to exhaustive search, the better the solution. I still pick greedy because the goal is not "find the optimal placement" but "stop the fighting." Dropping concurrency from 4 to 2 removes essentially all the operational pain, so there is no reason to pour compute into it. Anything you add to an unattended pipeline is safer the simpler it is, so it doesn't become its own failure source.
There is another point: with greedy, the order in which you move tasks matters. Handle the movable tasks that sit in the busiest windows first, and you tend to reach a flatter solution. Sort the movable tasks by their fire-time congestion in descending order before passing them to staggerOffsets to get that effect.
On the operational side, keeping durationMin close to reality also matters. A generation task that includes clone and build occupies very different time from a light task that only checks logs, so a single flat assumption skews how the peak looks. I pull the median duration from a few runs of logs and put that into durationMin.
Next step
Start by gathering the crons of your own scheduled tasks in one place and running collisionMap to print "the minutes whose value length is 2 or more." That list of tasks firing in the same minute is exactly the collision to fix first. Once you can see it, the line between the slots to protect and the slots you're free to move tends to decide itself.
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.