Resource Leveling
By default Clockwork only reports a resource that is over-allocated (a resource_capacity_breach conflict). With resourceLeveling: 'delay' it resolves it by delaying tasks until the resource is free.
Planning v2 availability. Merged to
mainand deployed to production (platform.clockwork-co.com; F1 to F7 and F9 to F11 on 2026-10-09, F12 pool assignment and the security fixes on 2026-10-12). The platform flagCLOCKWORK_PLANNING_V2is currently on globally, in production and in preview, as of 2026-10-12: every organization gets planning-v2 behavior, and with the flag off the planning-v2 endpoints answer404. Per-organization gating (CLOCKWORK_PLANNING_V2=orgs) is merged but not enabled in production, so the global flag is the only access control in effect. Exact dates and commits: Changelog.
off is the default and behaves exactly as before. The resourceLeveling field needs the platform flag CLOCKWORK_PLANNING_V2.
Two runs that each need both slots of a press, ready at the same time, are the classic case. With leveling off the plan keeps them together and reports the overload; with delay the second run waits:
const start = new Date(Math.ceil((Date.now() + 2 * 3_600_000) / 60_000) * 60_000).toISOString()
/** Two independent 60-minute press runs that can both start at `start`. */
function plan(unitsPerRun: number, resourceLeveling: 'off' | 'delay') {
return client.workflowInstances.createWithGraph({
instance: { name: `Press plan ${suffix} ${unitsPerRun}/${resourceLeveling}`, resourceLeveling },
resources: [{ ref: 'press', name: `Press ${suffix}`, type: 'asset', capacity: 2 }], // 2 slots
tasks: ['run-a', 'run-b'].map((ref) => ({
ref, name: ref, durationMinutes: 60, earliestStart: start,
requirements: [{ resourceRef: 'press', units: unitsPerRun }],
})),
})
}
const peak = (r: Awaited<ReturnType<typeof plan>>) => r.data.schedule.utilization.find((u) => u.resourceRef === 'press')!.peakUnits
const startOf = (r: Awaited<ReturnType<typeof plan>>, ref: string) => r.data.schedule.tasks.find((t) => t.ref === ref)!.plannedStart
// 1 slot each: both runs fit side by side (1 + 1 <= 2)
const oneEach = await plan(1, 'off')
if (peak(oneEach) !== 2 || oneEach.data.schedule.conflicts.length !== 0) throw new Error('1+1 on 2 slots should fit')
if (startOf(oneEach, 'run-a') !== startOf(oneEach, 'run-b')) throw new Error('both should start together')
// both slots each, leveling off: the planner keeps both at the same time and REPORTS the overload
const reported = await plan(2, 'off')
if (peak(reported) !== 4) throw new Error('expected 4 units in use on a 2-slot press')
const breach = reported.data.schedule.conflicts
if (breach.length !== 1 || breach[0]!.type !== 'resource_capacity_breach') throw new Error('expected one breach: ' + JSON.stringify(breach))
console.log('off :', breach[0]!.description)
// both slots each, leveling delay: one run waits for the other
const leveled = await plan(2, 'delay')
if (peak(leveled) !== 2 || leveled.data.schedule.conflicts.length !== 0) throw new Error('delay should resolve it')
const a = new Date(startOf(leveled, 'run-a')).getTime()
const b = new Date(startOf(leveled, 'run-b')).getTime()
if (Math.abs(a - b) !== 60 * 60_000) throw new Error('the second run should start 60 minutes after the first')
console.log('delay: second run starts', Math.abs(a - b) / 60_000, 'minutes after the first')Runnable: docs-examples/resources-capacity.mts. You can also combine leveling with an anchor (anchor and schedulingDirection: 'backward' in instance, see Anchored Scheduling) so each task lands at its latest feasible time.
Capacity is checked on occupied time — see Resource Requirements — so a task that holds a resource only at its start and end leaves the middle free for other work.
How tasks are ordered
A deterministic priority list: lowest slack first, then earliest start, then id. Each task is placed at the earliest time that satisfies every dependency (all four types, lags, travel) and has free capacity for every occupied stretch of every resource it needs. The same input always produces the same schedule, and recomputing with nothing changed changes nothing.
It also respects:
- Maximum lags — a predecessor is delayed as needed to keep the limit (see Dependencies).
- Anchors and windows — in backward mode each task lands at its latest feasible start.
- Fixed, started and completed tasks — they never move and they hold their resources. A started task that has run past its planned end keeps its resources until it completes.
When a task starts, it is pinned where it started. Delaying an in-progress task re-levels only the unstarted work downstream of it; independent work is untouched.
Leveling is also where "any one of these operators" is decided: with assignmentPolicy: 'balance_load' | 'earliest_finish' the leveler picks a member of a capability: pool for each pool requirement as it places the task. See Assign from a pool.
Availability windows
A resource can exist only for part of the time, such as an operator who arrives at 11:00 and leaves at 20:00. Put absolute windows in the resource's availability array (ISO 8601, { from, to } or { start, end }):
// ── Absolute availability windows ────────────────────────────────────────────────────
// An operator who is only on site 11:00-20:00 UTC, three days from now.
const day = new Date()
day.setUTCDate(day.getUTCDate() + 3)
const at = (h: number) => new Date(Date.UTC(day.getUTCFullYear(), day.getUTCMonth(), day.getUTCDate(), h)).toISOString()
const windows = [{ from: at(11), to: at(20) }] // { start, end } is accepted too
// A 60-minute job that wants to start at 09:00, before the operator arrives
const job = (resourceLeveling: 'off' | 'delay') =>
client.workflowInstances.createWithGraph({
instance: { name: `Window plan ${suffix} ${resourceLeveling}`, resourceLeveling },
resources: [{ ref: 'operator', name: `Operator ${suffix} ${resourceLeveling}`, type: 'human', capacity: 1, availability: windows }],
tasks: [{ ref: 'setup', name: 'Set up the press', durationMinutes: 60, earliestStart: at(9), requirements: [{ resourceRef: 'operator' }] }],
})
const off = await job('off')
const breach = off.data.schedule.conflicts
if (breach.length !== 1 || breach[0]!.type !== 'resource_capacity_breach' || !breach[0]!.description.includes('outside its availability')) {
throw new Error('expected an availability breach: ' + JSON.stringify(breach))
}
console.log('off :', breach[0]!.description)
const delayed = await job('delay')
const placed = delayed.data.schedule.tasks[0]!
if (delayed.data.schedule.conflicts.length !== 0 || placed.plannedStart !== at(11)) throw new Error('expected the task to wait for the window: ' + placed.plannedStart)
console.log('delay: starts at', placed.plannedStart)Runnable: docs-examples/resources-state-availability.mts.
- Only the resource's occupied time must fall inside the union of its windows (a
bookendstask may start before the operator arrives if the attended stretches are inside). - Windows bind through the planner: with
resourceLeveling: 'delay'tasks wait for (or slide into) the next window that can hold them, in forward and anchored mode. With leveling off, occupied time outside the windows is reported as aresource_capacity_breachconflict ("outside its availability"). - An assignment longer than every window can never fit: you get that conflict, not an error.
- A resource with no capacity but with windows is unconstrained except for its windows. No window entries = always available, as before. Weekly recurring slots (
daysOfWeek/startTime/endTime) are a different shape and are not interpreted here. - With planning v2 on, a window-shaped entry that is unusable (not a date, or ending before it starts) is a
400, so a typo is not silently "always available".
When no feasible schedule exists
You still get the best schedule, plus explicit conflicts rather than an error:
| Conflict | Meaning |
|---|---|
resource_capacity_breach | a requirement asks for more units than the resource has, or fixed/started work already over-uses it |
max_lag_exceeded | a maximum lag cannot be honored (for example the predecessor already started) |
anchor_infeasible | the plan cannot finish by the anchor; shortfallMinutes says by how much |
Performance
Planning 200 tasks across 10 resources takes a few milliseconds in the engine. A full recompute, including loading and saving it, takes roughly 100–300 ms against a database on the same machine and issues about 20 database statements whatever the size of the job (measured at 100 and 400 tasks). Over a network every statement is a round trip, so the real figure is about 20 round trips plus that local time: roughly 0.8 s at 20 ms and 2.5 s at 70 ms of database latency. Keep the platform and its database in the same region. See docs/RECOMPUTE_PERFORMANCE.md in the repository for the method and numbers.
Known limitations
- Leveling is only available when you create the job with
createWithGraph(resourceLevelingis not accepted byPOST /workflow-instances, which ignores it without an error). - A task longer than every availability window, or a claim that can never fit, cannot be levelled: you get the conflict, not an error.
- Ties are broken by lowest slack, then earliest start, then id; in
createWithGraphids are handed out in byte order of the taskrefs, so renaming refs can change how ties break. - Resource edits replan in the background, not inline: expect a few seconds of lag, at most 25 jobs per edit, and best-effort behaviour on hosts that cannot run work after the response (see below).
Notes
- Leveled instances are planner-managed: edits (delay, complete, requirements, constraints, dependencies) recompute the whole instance.
earliestStartstays your lower bound; the chosen placement isplannedStart/plannedEnd. - Leveled instances react to resource edits (see "When a resource changes").
When a resource changes
With planning v2 on, editing a resource's capacity, availability windows, status (unavailable, retired) or shared state (stateKey, stateTolerance, stateSetupMinutes), or adding or removing a member of a capability pool, replans every planner-managed job (leveled or backward-anchored) that has an unstarted task using that resource or pool. Tasks that have started, finished or are fixed never move.
const { data, recompute } = await client.resources.update(oven.id, { capacity: 1 })
console.log(recompute) // { scheduled: 3, skipped: 0, capped: false }
// a few seconds later each affected job emits schedule.changed with reason "resource_changed"- After the response. The replan runs in the background, one job at a time, so
schedule.changedand the newplannedStartvalues show up a moment after thePATCHreturns. - Limits. At most 25 jobs per edit, most recently updated first (
capped: truewhen more were affected: callworkflowInstances.recomputefor the rest). A job already replanned after the edit is skipped. An edit that arrives while the same job is being replanned is coalesced into one extra pass. Only one automatic replan per job runs at a time; if another holds it, this one is skipped. - Opt out.
update(id, input, { recompute: false })(?recompute=false) leaves every plan as it is, e.g. while you batch several edits; follow withworkflowInstances.recompute(jobId). - Best effort. On hosts without background execution (anything other than Vercel's
waitUntil) the replan is a detached promise that a short-lived process can cut off. A failure is logged and never fails the edit. - Not covered.
tasks.assignResource(a label, not a claim) andresources.allocatedo not trigger a replan; jobs that are not planner-managed recompute on their next task change.