◉CLAUDE LABJP
●2.1.301 — Adds the vim % motion, LaTeX math rendering in the VS Code panel, and a retry-watchdog limit for usage-limit waits (10/15)●2.1.299 — Conversations over 200K tokens left idle about 54 minutes get compacted; MCP allow rules now match only the server they name●SONNET 4.5 — Retires on the Claude API on 11/30, 44 days left. Migrate to Sonnet 5.5●WINDOWS — Reports say hooks break silently only on Japanese-locale Windows; check the character encoding first●FAST MODE — Opus 5.5 fast mode is out of research preview and no longer needs the beta header (10/15)●NEW — I Put a Ten-Line Promise File at the To…●2.1.301 — Adds the vim % motion, LaTeX math rendering in the VS Code panel, and a retry-watchdog limit for usage-limit waits (10/15)●2.1.299 — Conversations over 200K tokens left idle about 54 minutes get compacted; MCP allow rules now match only the server they name●SONNET 4.5 — Retires on the Claude API on 11/30, 44 days left. Migrate to Sonnet 5.5●WINDOWS — Reports say hooks break silently only on Japanese-locale Windows; check the character encoding first●FAST MODE — Opus 5.5 fast mode is out of research preview and no longer needs the beta header (10/15)●NEW — I Put a Ten-Line Promise File at the To…
Articles/Cowork
◈ Cowork/2026-07-02Advanced

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.

Cowork48Scheduled Tasks14cron4Automation45Concurrency3

✦ Premium Article

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.

✦

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.

or
Unlock all articles with Membership →
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.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

Related Articles

◈ Cowork2026-09-24
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.
◈ Cowork2026-09-18
How I decide between Cowork and Claude Code: by where approvals land
Cowork and Claude Code run the same Claude, so I stopped comparing features and started sorting work by one question: can a human approval interrupt this step, or not? Here is the line I draw and the code I keep on the unattended side.
◈ Cowork2026-10-17
When a Scheduled Task Keeps Saying SUCCEEDED but Produces Nothing, Check Outbound Traffic in the First Minute
The run history is green, but the output file is still yesterday's. A short probe and a freshness check that tell you in the first minute whether outbound calls are being refused with a 403 on CONNECT.