Conflicts
A Conflict is a scheduling violation detected by the Clockwork engine. Conflicts are created automatically — you never create them manually. They surface when a deadline would be missed, a person has overlapping tasks, a resource is over-allocated, a plan cannot meet its anchor, a maximum lag is exceeded, or travel time between locations is insufficient.
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.
When to use client.conflicts
- Building an operations dashboard that highlights at-risk workflows
- Automating remediation — e.g., auto-delay a task when a conflict is detected
- Snoozing non-critical conflicts to reduce noise
- Integrating with alerting systems via the
conflict.detectedwebhook event
Methods
| Method | Description |
|---|---|
list(opts?) | List conflicts (optionally filter by resolved status) |
get(conflictId) | Fetch a single conflict |
resolve(conflictId) | Mark as manually resolved |
snooze(conflictId, input) | Snooze until a future time |
conflicts.list(opts?)
// Open (unresolved) conflicts in the project
const { data: open } = await client.conflicts.list({ resolved: false })
for (const conflict of open) {
console.log(conflict.type, conflict.description, conflict.slackViolationSec)
}Filter options
| Option | Type | Description |
|---|---|---|
projectId | string | Filter to a specific project |
resolved | boolean | false: only open conflicts. true: only resolved ones. Omitted: both |
The query string accepts true, false, 1 and 0; anything else is a 400 VALIDATION_ERROR. (Servers released before this fix read resolved=false as true; on those, call list() and filter on resolvedAt yourself.)
conflicts.resolve(conflictId)
await client.conflicts.resolve('con_abc123')conflicts.snooze(conflictId, input)
Records that you want to ignore the conflict until the given time (snoozedUntil) and writes a conflict.snoozed entry to the event log. The platform does not act on it: the conflict still appears in list() (including with resolved: false), and nothing re-raises it when the time passes. Filter on snoozedUntil in your own code if you want snoozed conflicts hidden.
await client.conflicts.snooze('con_abc123', {
until: '2026-07-01T09:00:00Z',
})Conflict types
| Type | Cause |
|---|---|
deadline_violated | A task cannot complete before its deadline given current scheduling |
resource_contention | One person (the task's assignee) has overlapping tasks. Stored on the later task, with affectedActorId set and slackViolationSec = the overlap |
travel_time_violated | Consecutive tasks require more travel time than is available between them |
resource_capacity_breach | A resource is over-allocated: occupied units exceed capacity at some moment, a claim falls outside the resource's availability windows, or two claims differ by more than the resource's shared-state tolerance (or need a changeover). See below. Older capability-based requirements can also raise it as Cannot complete before deadline — resource contention delays finish past the deadline. with slackViolationSec 0 |
constraint_violated | A constraint is broken: a max_concurrent group is over its cap, or a task is placed outside its anchor_window / deadline constraint |
anchor_infeasible | A backward plan cannot meet its anchor. shortfallMinutes says by how much |
max_lag_exceeded | A dependency's maxLagMinutes cannot be honored |
capacity_breach | A person's working-hours slots cannot fit the task before its deadline: Cannot complete before deadline — overloaded by N day(s). (slackViolationSec = N x 86,400), or, for a task due within a day when the assignee has no availability schedule, No availability schedule configured — cannot verify this task fits before its deadline. (slackViolationSec 0). Part of the older per-person capacity pass, unrelated to resource capacity |
These eight values are the whole ConflictType union and the database enum; none has been removed. Which are produced by planning v2 (anchor_infeasible, max_lag_exceeded, and the occupancy-, window- and state-based form of resource_capacity_breach) is spelled out below.
resource_contention or resource_capacity_breach?
resource_contention | resource_capacity_breach | |
|---|---|---|
| About | a person: tasks with the same assignee that overlap in time | a resource: units claimed through requirements exceed its capacity, or windows or state are broken |
| Units, occupancy, bookends | not considered: one task = one slot | considered |
| Set by | assigneePlatformUserId | requirements / setRequirements, and resourceLeveling can resolve it |
affectedActorId | the person | null |
tasks.assignResource and resources.allocate raise neither (the engine can also compare tasks that hold the same directly allocated resource, but the platform does not currently pass that information to it).
Over-allocated resources (resource_capacity_breach)
When a task claims units of a resource (see Resource Requirements), the planner looks only at the occupied time of each claim: the whole task for full, only the first and last minutes for bookends, nothing for none. At every instant it adds up the units of the claims and compares the total with the resource's capacity. Each maximal stretch where the total is higher produces one conflict:
- it is attached to the claim that started last in that stretch (the one that tipped it over; ties go to the smaller claim, then name, then id);
slackViolationSecis the length of the stretch in seconds;- other jobs that claim the same resource count toward the total.
A press with 2 slots and two runs that each need both slots, at the same time:
{
"taskId": "…",
"ref": "run-b",
"type": "resource_capacity_breach",
"description": "Resource is over capacity (2) while \"run-b\" runs.",
"slackViolationSec": 3600
}Availability and shared-state breaches use the same type with different descriptions ("Set up the press" needs the resource 60 minute(s) outside its availability.).
Contention is only detected, unless you opt in
By default (resourceLeveling: 'off') the planner does not move anything to avoid an over-allocation. The request succeeds, the tasks stay where they are, and the conflict is reported (in schedule.conflicts and in conflicts.list()). Treat a non-empty conflicts array as "this plan does not fit", not as success. To have the planner delay tasks until the resource is free, create the job with resourceLeveling: 'delay' (Resource Leveling).
Fixing it by hand when leveling is off
Find the conflict, delay the task it names, and look again. The conflict is stored on the task that tipped the resource over, so that is the one to move:
const minutesFromNow = (m: number) => new Date(Math.ceil(Date.now() / 60_000) * 60_000 + m * 60_000).toISOString()
type Found = { ref: string; type: string; description: string; slackViolationSec: number; shortfallMinutes?: number }
const types = (conflicts: Found[]) => conflicts.map((c) => c.type)
// ── 1. Contention is only DETECTED unless the job opts into leveling ─────────────────
// A press with 2 slots; two independent runs that each need both slots, both ready now.
const { data: off } = await client.workflowInstances.createWithGraph({
instance: { name: `Press plan ${suffix}`, resourceLeveling: 'off' }, // 'off' is the default
resources: [{ ref: 'press', name: `Press ${suffix}`, type: 'asset', capacity: 2 }],
tasks: ['run-a', 'run-b'].map((ref) => ({
ref, name: `${ref} ${suffix}`, durationMinutes: 60, earliestStart: minutesFromNow(60),
requirements: [{ resourceRef: 'press', units: 2 }],
})),
})
// 201: the request succeeds, the plan still has both runs at the same time, and the conflict is reported
if (types(off.schedule.conflicts).join() !== 'resource_capacity_breach') throw new Error('expected one breach')
const breach = off.schedule.conflicts[0]!
console.log(breach.type, '-', breach.description, `(${breach.slackViolationSec} s)`)
const starts = off.schedule.tasks.map((t) => t.plannedStart)
if (starts[0] !== starts[1]) throw new Error('nothing moved: both runs are still planned together')
// The same conflict is stored. `resolved: false` lists open conflicts only; keep the ones on this job's tasks.
const taskIds = Object.values(off.refs)
const open = async () => (await client.conflicts.list({ resolved: false })).data.filter((c) => taskIds.includes(c.taskId))
let stored = await open()
if (stored.length !== 1 || stored[0]!.taskId !== breach.taskId) throw new Error('expected the breach to be stored on the blamed task')
// ── 2. Manual remediation when leveling is off: delay the blamed task, then re-check ───
// Move it to start when the other run has finished (60 minutes later).
await client.tasks.delay(stored[0]!.taskId, { delayMinutes: 60, reason: 'Press is busy with the other run' })
stored = await open()
if (stored.length !== 0) throw new Error('expected the delay to clear the conflict: ' + JSON.stringify(stored))
console.log('after delay: no open conflicts')Runnable: docs-examples/conflicts-detect-and-fix.mts. resolve only marks a conflict as handled; it does not change the plan, and a recompute that still finds the problem raises it again.
Planner conflicts with real responses
These come from createWithGraph (and simulate) in schedule.conflicts; the same conflicts are stored and listed with conflicts.list(). In the batch-graph response each conflict also carries the task's ref.
anchor_infeasible (plus constraint_violated for the anchor window). Three hours of work must finish within one hour of the anchor, and run must finish 5 to 30 minutes before it:
{ "ref": "run", "type": "constraint_violated",
"description": "Task \"Print run\" is placed 155 minute(s) outside its allowed window.",
"slackViolationSec": 9288 }
{ "ref": "plates", "type": "anchor_infeasible",
"description": "The plan cannot meet its anchor: it is short by 150 minutes (tasks cannot start early enough).",
"slackViolationSec": 8988, "shortfallMinutes": 150 }anchor_infeasible is attached to the first task of the plan; shortfallMinutes is the shortfall rounded up to whole minutes. The best schedule is still returned, with dependencies intact. See Anchored Scheduling.
max_lag_exceeded. "Cut within 30 minutes of printing", but printing is fixed and the cut cannot start before 4 hours later:
{ "ref": "cut", "type": "max_lag_exceeded",
"description": "\"Cut\" runs 150 minute(s) past the maximum lag of 30 minute(s) after \"Print\".",
"slackViolationSec": 9000 }It is attached to the later task, and slackViolationSec is how far past the limit it runs. If the earlier task is not fixed or started, the planner delays it to keep the limit and raises no conflict. See Dependencies.
deadline_violated: { "type": "deadline_violated", "description": "Task \"Print\" cannot finish by its deadline.", "slackViolationSec": 3588 } for two hours of work due in one hour (the seconds are how late it would finish).
// ── 3. Other conflict types, with real responses ────────────────────────────────────
// deadline_violated: 2 hours of work, due in 1 hour
const deadline = await client.workflowInstances.createWithGraph({
tasks: [{ ref: 'a', name: `Print ${suffix}`, durationMinutes: 120, deadline: minutesFromNow(60) }],
})
if (types(deadline.data.schedule.conflicts).join() !== 'deadline_violated') throw new Error('expected deadline_violated')
// anchor_infeasible (+ constraint_violated for the anchor window): 3 hours of work must finish in 1 hour
const anchored = await client.workflowInstances.createWithGraph({
instance: { anchor: { type: 'finish', at: minutesFromNow(60) }, schedulingDirection: 'backward' },
tasks: [
{ ref: 'plates', name: `Make plates ${suffix}`, durationMinutes: 180 },
{ ref: 'run', name: `Print run ${suffix}`, durationMinutes: 30, anchorWindow: { minOffsetMinutes: -30, maxOffsetMinutes: -5, applyTo: 'finish' } },
],
dependencies: [{ from: 'plates', to: 'run' }],
})
const kinds = types(anchored.data.schedule.conflicts).sort().join()
if (kinds !== 'anchor_infeasible,constraint_violated') throw new Error('unexpected: ' + kinds)
const infeasible = anchored.data.schedule.conflicts.find((c) => c.type === 'anchor_infeasible')!
if (infeasible.shortfallMinutes !== Math.ceil(infeasible.slackViolationSec / 60)) throw new Error('shortfallMinutes mismatch')
console.log('anchor_infeasible short by', infeasible.shortfallMinutes, 'minutes')
// max_lag_exceeded: "cut within 30 minutes of printing", but printing is fixed and the cut cannot start before +4 h
const lag = await client.workflowInstances.createWithGraph({
tasks: [
{ ref: 'print', name: `Print ${suffix}`, durationMinutes: 60, isFixed: true, earliestStart: minutesFromNow(0) },
{ ref: 'cut', name: `Cut ${suffix}`, durationMinutes: 30, earliestStart: minutesFromNow(240) },
],
dependencies: [{ from: 'print', to: 'cut', maxLagMinutes: 30 }],
})
if (types(lag.data.schedule.conflicts).join() !== 'max_lag_exceeded') throw new Error('expected max_lag_exceeded')
console.log(lag.data.schedule.conflicts[0]!.description)Type reference
type ConflictType =
| 'deadline_violated'
| 'resource_contention'
| 'travel_time_violated'
| 'capacity_breach'
| 'resource_capacity_breach'
| 'constraint_violated'
| 'anchor_infeasible'
| 'max_lag_exceeded'
interface Conflict {
id: string
taskId: string
type: ConflictType
description: string
affectedActorId: string | null
slackViolationSec: number
resolvedAt: string | null
snoozedUntil: string | null
notificationSent: boolean
createdAt: string
}Known limitations
- Older SDK versions.
@clockwork/sdkreleases before the security-and-correctness fixes (#274, 2026-10-12) exported aConflictTypeunion withoutanchor_infeasibleandmax_lag_exceeded, soconflict.type === 'anchor_infeasible'did not compile; on those versions widen it, for example(conflict.type as string) === 'anchor_infeasible'. - Stale conflicts across jobs. A recompute rewrites the open conflicts of the job it ran for. If job A's change frees a resource that job B was over-allocating, B's stored conflict stays until B recomputes (any mutation in B).
- Stored vs returned. A stored conflict has no
reforshortfallMinutes; those exist only in the plan response.tasks.createBatchreturns no conflicts at all: read them withconflicts.list(). resource_contentionis about a person with overlapping tasks.tasks.assignResourceon two overlapping tasks does not raise it, whatever the resource's capacity: use requirements to model a shared resource.
Related
- Constraints — attach rules that generate conflicts when violated
- Resource Requirements and Resource Leveling — over-allocation and how to resolve it
- Opportunities — positive scheduling signals (the complement of conflicts)
- Events — subscribe to
conflict.detectedwebhook events