Leveling Resources#

This guide shows how to remove resource overallocation from a plan with level. It covers what leveling changes, how it picks the task that gives way, how to limit it to part of the plan, and how to read, preview and undo the result.

How leveling works#

A resource is overallocated when the tasks booked on it need more than its capacity at the same time. solve only reports this, as overallocations windows in the result. level fixes it by moving work later. It never changes assignments, resources or capacities. It changes two task fields:

  • leveling_delay delays the whole task.
  • pauses splits a task: the task stops working for a while and picks up again later.

Leveling looks at the plan in buckets: day buckets when config.duration_unit is "day", hour buckets for "hour" and "minute". The load of a resource in a bucket is the sum of value over the assignments that book working time in that bucket. A task counts with its full value as soon as it works at any time in the bucket. When the load is higher than the resource's capacity (default config.default_resource_capacity, which is 1), the bucket is overallocated. A run of overallocated buckets in a row is a window.

level works through the plan from left to right, in rounds:

  1. Find the earliest overallocated bucket.
  2. Collect the tasks that contribute to it, the contenders, and sort them by the selection order.
  3. Move the weakest contender just past the bucket, with a delay or a pause, until the load fits.
  4. Reschedule the project, so links and constraints push the successors along.
  5. Repeat until no overallocated bucket with a movable contender is left.

Some tasks never move. Leveling leaves them alone and calls them the fixed set:

  • tasks with auto_scheduling: false
  • completed tasks (progress: 1, when config.use_progress is on)
  • tasks with an "mso" or "mfo" constraint (when config.apply_constraints is on)
  • tasks with priority: 1000
  • summaries (their dates follow their children)
  • tasks outside scope.tasks, when you set it

If a proposed move doesn't stick, because a constraint or another hard rule pulls the task back, leveling reverts it and freezes the task for the rest of the run. Hard requirements always win. Whatever leveling can't resolve stays in overallocations, and each remaining window gets a reason.

A few rules hold for every run:

  • level commits. It solves the current state first, writes the delays and pauses into the project and accepts the leveled dates as the new baseline. To show leveling as a proposal, run it on a clone(). See Previewing and clearing.
  • Leveling is forward-only. A project with config.schedule_from_end: true gets a validation error at the path config.schedule_from_end.
  • The output is deterministic. Every tie ends in input order, so the same data and options always give the same result.
  • There's no round limit. A run ends when nothing is left to resolve. The call is synchronous, so on large projects run it off the main thread. See Run in a worker.

Leveling a project#

Call level with "{}" (all leveling options at their defaults) after the project is solved. The examples on this page use the default calendar (Monday to Friday, 08:00-12:00 and 13:00-17:00) and start on Monday, August 10, 2026.

const { JsonProject } = require("./pkg-node/plen_wasm.js");

function unwrap(text) {
  const response = JSON.parse(text);
  if (!response.ok) {
    const { kind, code, message, path } = response.error;
    throw new Error(`${kind} ${code ?? ""}: ${message} (at ${path})`);
  }
  return response.result;
}

// Two 2-day tasks, both booked full time on dev1 from Monday.
const data = {
  tasks: [
    { id: "A", start_date: "2026-08-10 08:00", duration: 2 },
    { id: "B", start_date: "2026-08-10 08:00", duration: 2 },
  ],
  resources: [{ id: "dev1" }],
  assignments: [
    { task: "A", resource: "dev1", value: 1 },
    { task: "B", resource: "dev1", value: 1 },
  ],
};

const project = new JsonProject("{}");
try {
  const solved = unwrap(project.solve(JSON.stringify(data), "{}"));
  for (const over of solved.overallocations ?? []) {
    for (const window of over.windows ?? []) {
      console.log(`${over.resource}: ${window.from} .. ${window.to}, peak ${window.peak} of ${window.capacity}`);
    }
  }

  const leveled = unwrap(project.level("{}"));
  for (const update of leveled.updates) {
    console.log(`${update.id}: ${update.start_date} .. ${update.end_date}, delay ${update.leveling_delay}`);
  }
} finally {
  project.free();
}

Output:

dev1: 2026-08-10 08:00 .. 2026-08-11 17:00, peak 2 of 1
B: 2026-08-12 08:00 .. 2026-08-14 08:00, delay 2

solve reports one window on dev1 over Monday and Tuesday, with a peak load of 2. level keeps A and moves B to Wednesday. Both tasks are equal, so the input-order tie-break decides: the later task moves.

The update for B is a plain Mutation:

{
  "end_date": "2026-08-14 08:00",
  "id": "B",
  "kind": "task",
  "leveling_delay": 2,
  "start_date": "2026-08-12 08:00"
}

Leveling diffs its updates against the state the call started from, so you can apply them to the model you already hold. Next to the new dates, each entry carries the task's new leveling_delay and, when it changed, its complete pauses list. An update entry is a valid TaskUpdate, so you can feed it back through apply unchanged.

Delays and pauses#

leveling_delay and pauses are elapsed time, in the task's duration unit: with day units, 1 is 24 hours of calendar time, weekends and nights included. duration and lag stay working time.

  • A delay shifts the task's start from its driven position (the start that links and constraints give it). Draw it as the stretch [start - delay, start) before the bar.
  • A pause { "at", "length", "by" } starts at after the task start and lasts length. The leveler sets "by": "leveling" on every pause it writes. Draw pauses as gaps in the bar. They need no calendar.

Which one leveling picks depends on the contender. A task that starts inside the overallocated bucket has no work before it, so it gets a whole-task delay. A task that's already running in that bucket can be paused: it keeps the work it has done and resumes after the contention. Pausing a running task leaves no idle hole behind it. Delaying it would. Set allowSplits: false to get whole-task delays only.

const { JsonProject } = require("./pkg-node/plen_wasm.js");

function unwrap(text) {
  const response = JSON.parse(text);
  if (!response.ok) {
    const { kind, code, message, path } = response.error;
    throw new Error(`${kind} ${code ?? ""}: ${message} (at ${path})`);
  }
  return response.result;
}

// A runs Monday and Tuesday. B joins on Tuesday and feeds C, so it has less slack.
const data = {
  tasks: [
    { id: "A", start_date: "2026-08-10 08:00", duration: 2 },
    { id: "B", start_date: "2026-08-11 08:00", duration: 2 },
    { id: "C", start_date: "2026-08-13 08:00", duration: 5 },
  ],
  links: [{ id: "bc", source: "B", target: "C", type: "fs" }],
  resources: [{ id: "dev1" }, { id: "dev2" }],
  assignments: [
    { task: "A", resource: "dev1", value: 1 },
    { task: "B", resource: "dev1", value: 1 },
    { task: "C", resource: "dev2", value: 1 },
  ],
};

const project = new JsonProject("{}");
try {
  unwrap(project.solve(JSON.stringify(data), "{}"));

  // Splits allowed (the default): A is paused, B and C keep their dates.
  const split = project.clone();
  try {
    for (const update of unwrap(split.level("{}")).updates) {
      console.log(`${update.id}: end ${update.end_date}, pauses ${JSON.stringify(update.pauses)}`);
    }
  } finally {
    split.free();
  }

  // Whole-task delays only: B moves past A, and C moves with it.
  const whole = project.clone();
  try {
    for (const update of unwrap(whole.level(JSON.stringify({ allowSplits: false }))).updates) {
      console.log(`${update.id}: ${update.start_date} .. ${update.end_date}`);
    }
  } finally {
    whole.free();
  }
} finally {
  project.free();
}

Output:

A: end 2026-08-14 08:00, pauses [{"at":1,"by":"leveling","length":2}]
B: 2026-08-12 08:00 .. 2026-08-14 08:00
C: 2026-08-14 08:00 .. 2026-08-21 08:00

With splits, A keeps its start and works Monday, pauses for two days (at: 1, length: 2), and finishes its second day on Thursday. B works in the pause, so the chain into C doesn't move. Without splits, A can't be paused and stays in place, so B is delayed whole and the link pushes C along. The update for A has no start_date, because its start didn't change.

Pauses only come from leveling. You can send pauses in task data, for example to restore a saved plan, but every pause belongs to the leveler: clearFirst removes all pauses from the in-scope tasks. A pause takes up no load, and a task without pauses never gets a pauses field in its update. The field rules are on the TaskData reference.

Leveling options#

LevelingOptions is a JSON object. Every field may be omitted or null, which means its default:

Field Default What it changes
scope whole project Which resources, tasks and dates leveling acts on.
basis derived from config.duration_unit Bucket size, "day" or "hour".
order "standard" Which contender moves first. See Selection order.
withinSlackOnly false Never push a task past the project end of the plan leveling started from.
clearFirst true Clear the existing delays and pauses on in-scope tasks before leveling.
allowSplits true Allow pauses. With false, every move is a whole-task delay.
step "minimal" How far one move goes.

Option names are camelCase, unlike the snake_case task fields they write to. Unknown fields are rejected with a parse error. There's no maxRounds, for example. See the LevelingOptions reference for the full contract.

Leveling part of a plan#

scope limits a run in three ways, and you can combine them:

  • tasks: only these tasks may move. Everything else acts like a fixed task.
  • resources: only contention on these resources triggers a move.
  • from / to: only buckets that start in [from, to) trigger a move. The range limits where leveling looks for contention, not where tasks land: a moved task may end up past to.
const { JsonProject } = require("./pkg-node/plen_wasm.js");

function unwrap(text) {
  const response = JSON.parse(text);
  if (!response.ok) {
    const { kind, code, message, path } = response.error;
    throw new Error(`${kind} ${code ?? ""}: ${message} (at ${path})`);
  }
  return response.result;
}

const data = {
  tasks: [
    { id: "A", start_date: "2026-08-10 08:00", duration: 2 },
    { id: "B", start_date: "2026-08-10 08:00", duration: 2 },
  ],
  resources: [{ id: "dev1" }],
  assignments: [
    { task: "A", resource: "dev1", value: 1 },
    { task: "B", resource: "dev1", value: 1 },
  ],
};

const project = new JsonProject("{}");
try {
  unwrap(project.solve(JSON.stringify(data), "{}"));

  // Only A may move, so A gives way even though B is later in input order.
  const onlyA = project.clone();
  try {
    const result = unwrap(onlyA.level(JSON.stringify({ scope: { tasks: ["A"] } })));
    for (const update of result.updates) {
      console.log(`${update.id}: ${update.start_date} .. ${update.end_date}`);
    }
  } finally {
    onlyA.free();
  }

  // Only contention from Wednesday on. The Monday-Tuesday window stays.
  const fromWednesday = project.clone();
  try {
    const result = unwrap(fromWednesday.level(JSON.stringify({ scope: { from: "2026-08-12" } })));
    for (const over of result.overallocations ?? []) {
      for (const window of over.windows ?? []) {
        console.log(`${over.resource}: ${window.from} .. ${window.to}, ${window.reason}`);
      }
    }
  } finally {
    fromWednesday.free();
  }
} finally {
  project.free();
}

Output:

A: 2026-08-12 08:00 .. 2026-08-14 08:00
dev1: 2026-08-10 08:00 .. 2026-08-11 17:00, out-of-scope

The second run leaves the Monday window alone and reports it with the reason out-of-scope. The result still says converged: true, because the run did everything it was asked to do. The reason tells you why the window is still there.

An unknown id in scope.tasks or scope.resources fails the call with a validation error at the path scope.tasks or scope.resources:

{
  "error": {
    "code": null,
    "kind": "validation",
    "message": "unknown task \"X\"",
    "path": "scope.tasks"
  },
  "ok": false
}

Staying within slack#

With withinSlackOnly: true, leveling never pushes a task further than its total slack. The slack is measured against the project end of the plan the run started from, so leveling can't make the project finish later. If a contender can't clear the bucket within its slack, leveling skips it and tries the next one. If no contender fits, the window stays with the reason slack-exhausted.

In the two-task plan from Leveling a project, both tasks end the project, so neither has slack. level with {"withinSlackOnly": true} moves nothing and returns:

{
  "baselineId": "โ€ฆ",
  "conflicts": [],
  "converged": true,
  "cycles": [],
  "notes": [],
  "overallocations": [
    {
      "resource": "dev1",
      "windows": [
        {
          "capacity": 1,
          "from": "2026-08-10 08:00",
          "overload": 2,
          "peak": 2,
          "reason": "slack-exhausted",
          "tasks": ["A", "B"],
          "to": "2026-08-11 17:00"
        }
      ]
    }
  ],
  "rounds": 0,
  "updates": []
}

rounds is 0, because skipping a bucket isn't a round.

The slack leveling uses is internal. It ignores the existing delays and uses that fixed project end, so it can differ from analytics.slack in a solve result.

Fewer rounds with the skip step#

By default a move clears one bucket. Then leveling reschedules and re-selects at the next bucket, so the burden of a long window can switch between contenders bucket by bucket. step: "skip" moves the task past the whole window in one move. That takes fewer rounds and reschedules less often. Use it for large projects where level is slow.

const { JsonProject } = require("./pkg-node/plen_wasm.js");

function unwrap(text) {
  const response = JSON.parse(text);
  if (!response.ok) {
    const { kind, code, message, path } = response.error;
    throw new Error(`${kind} ${code ?? ""}: ${message} (at ${path})`);
  }
  return response.result;
}

const data = {
  tasks: [
    { id: "A", start_date: "2026-08-10 08:00", duration: 2 },
    { id: "B", start_date: "2026-08-10 08:00", duration: 2 },
  ],
  resources: [{ id: "dev1" }],
  assignments: [
    { task: "A", resource: "dev1", value: 1 },
    { task: "B", resource: "dev1", value: 1 },
  ],
};

function levelCopy(project, options) {
  const copy = project.clone();
  try {
    return unwrap(copy.level(JSON.stringify(options)));
  } finally {
    copy.free();
  }
}

const project = new JsonProject("{}");
try {
  unwrap(project.solve(JSON.stringify(data), "{}"));
  const minimal = levelCopy(project, {});
  const skip = levelCopy(project, { step: "skip" });

  // Same dates for B (Wednesday to Friday), in 2 rounds and in 1.
  console.log(`minimal: ${minimal.rounds} rounds, skip: ${skip.rounds} rounds`);
  console.log(JSON.stringify(minimal.updates) === JSON.stringify(skip.updates)); // true
} finally {
  project.free();
}

Output:

minimal: 2 rounds, skip: 1 rounds
true

Here both steps give the same plan. That's not guaranteed: buckets the skip step jumps over never get a new selection, so on real plans the two can level differently. If a jump hits a constraint or doesn't fit the slack, leveling falls back to the one-bucket move for that task.

Selection order#

When several contenders share an overallocated bucket, order decides which one stays and which moves first. Every order ends in the same tie-break: the task later in input order moves.

order Who moves first
"standard" (default) Checked in order: a successor before its predecessor, more total slack, later start, lower priority, no dated constraint.
"priority" Lower priority first, then the "standard" checks.
"id" The task later in input order.

All three orders also apply the running rule. A task already working before the bucket stays, and the task that starts in the bucket moves, unless the running task can be paused instead. This keeps whole-task delays from leaving gaps in work that was already scheduled. Under "priority", a higher priority beats this rule: the lower-priority running task is paused, or delayed whole when splits are off.

priority is an integer from 0 to 1000 on TaskData, 500 by default. At 1000 the task never moves.

The dependency check exists because delaying a predecessor would push its successor along and resolve nothing. In this plan B comes first in the input, but an SS link from A makes A its predecessor, so B moves:

const { JsonProject } = require("./pkg-node/plen_wasm.js");

function unwrap(text) {
  const response = JSON.parse(text);
  if (!response.ok) {
    const { kind, code, message, path } = response.error;
    throw new Error(`${kind} ${code ?? ""}: ${message} (at ${path})`);
  }
  return response.result;
}

const data = {
  tasks: [
    { id: "B", start_date: "2026-08-10 08:00", duration: 2 },
    { id: "A", start_date: "2026-08-10 08:00", duration: 2 },
  ],
  links: [{ id: "ab", source: "A", target: "B", type: "ss" }],
  resources: [{ id: "dev1" }],
  assignments: [
    { task: "A", resource: "dev1", value: 1 },
    { task: "B", resource: "dev1", value: 1 },
  ],
};

const project = new JsonProject("{}");
try {
  unwrap(project.solve(JSON.stringify(data), "{}"));
  for (const update of unwrap(project.level("{}")).updates) {
    console.log(`${update.id}: ${update.start_date} .. ${update.end_date}`);
  }
} finally {
  project.free();
}

Output:

B: 2026-08-12 08:00 .. 2026-08-14 08:00

To protect a task from leveling, set its priority to 1000 or its auto_scheduling to false. With "priority": 1000 on B in the first example, A moves to Wednesday instead.

Reading the result#

level returns a LevelingResult in the success envelope. It has the same fields as the ScheduleResult that solve returns, flattened into one object, plus one more:

  • updates carry the new dates, delays and pauses. baselineId names the new baseline.
  • rounds is the number of rounds that proposed a move. Rounds that only skipped an unresolvable bucket don't count.

converged is the scheduler's convergence on the final plan, not a leveling flag. Leveling itself always runs until nothing is left to resolve.

overallocations lists the windows that remain. It's an empty array when leveling resolved everything. Every window from level has a reason. When a window has several immovable contenders, the first reason in this table wins:

reason Why the window remains
pinned-constraint A contender was frozen because its move didn't stick, or it has an "mso" / "mfo" constraint.
fixed-task A contender has auto_scheduling: false, or is a summary.
completed A contender has progress: 1 under config.use_progress.
priority-1000 A contender has priority: 1000.
slack-exhausted Every candidate was capped by withinSlackOnly.
out-of-scope The resource, the dates or every contender is outside scope.

A reason is data, not an error. It tells the user what to change to resolve the window: unpin a task, loosen a constraint, widen the scope.

const { JsonProject } = require("./pkg-node/plen_wasm.js");

function unwrap(text) {
  const response = JSON.parse(text);
  if (!response.ok) {
    const { kind, code, message, path } = response.error;
    throw new Error(`${kind} ${code ?? ""}: ${message} (at ${path})`);
  }
  return response.result;
}

// A is fixed. B is the only task that could move, but it must start by Monday.
const data = {
  config: { apply_constraints: true },
  tasks: [
    { id: "A", start_date: "2026-08-10 08:00", duration: 2, auto_scheduling: false },
    {
      id: "B",
      start_date: "2026-08-10 08:00",
      duration: 2,
      constraint_type: "snlt",
      constraint_date: "2026-08-10 08:00",
    },
  ],
  resources: [{ id: "dev1" }],
  assignments: [
    { task: "A", resource: "dev1", value: 1 },
    { task: "B", resource: "dev1", value: 1 },
  ],
};

const project = new JsonProject("{}");
try {
  unwrap(project.solve(JSON.stringify(data), "{}"));
  const result = unwrap(project.level("{}"));

  // The move was reverted, so nothing changed.
  console.log(`updates: ${result.updates.length}`);
  for (const over of result.overallocations ?? []) {
    for (const window of over.windows ?? []) {
      console.log(`${over.resource}: tasks ${window.tasks.join(", ")}, reason ${window.reason}`);
    }
  }
} finally {
  project.free();
}

Output:

updates: 0
dev1: tasks A, B, reason pinned-constraint

Leveling proposed a delay for B, the replan pulled B back to Monday, and leveling reverted the move. Nothing in the project changed. The window stays and reports pinned-constraint. With both tasks fixed and no constraint, the same window would report fixed-task.

The other window fields (from, to, capacity, peak, overload, tasks) are on the Overallocation reference. For the load of each bucket, see Resource load.

level returns an error envelope in these cases, and each one leaves the project as it was:

  • validation: an unknown scope id, a bad option value such as "step": "jump", or a backward-scheduled project (path config.schedule_from_end).
  • parse: an unknown option such as maxRounds, or a wrong type such as "allowSplits": "no".
  • project with code needs-resync: an earlier apply rejected an operation, and no solve with data has followed.

See Error handling.

Previewing and clearing#

Because level commits, run it on a clone to show leveling as a proposal. clone() is cheap: the data is shared until one copy is edited. The clone is a separate handle, so free it too.

function levelingProposal(project) {
  const draft = project.clone();
  try {
    const response = JSON.parse(draft.level("{}"));
    if (!response.ok) throw new Error(response.error.message);
    for (const update of response.result.updates) {
      console.log(`${update.id}: delay ${update.leveling_delay}, pauses ${JSON.stringify(update.pauses)}`);
    }
    return response.result;
  } finally {
    // The original project is untouched either way.
    draft.free();
  }
}

To accept the proposal, either keep the clone in place of the original (and free the original instead), or call level on the original with the same options. The step-by-step version is in Preview leveling. To score leveling as part of an alternative change set, use evaluate with the level option. See Evaluating alternatives.

There are two ways to remove leveling:

  • To undo it, set leveling_delay to 0 and pauses to an empty list on the task with apply, then call solve without data. The original contention comes back.
  • To level again from scratch, call level again. clearFirst is on by default, so the run first clears the delays and pauses of the in-scope tasks and levels from the clean plan. With clearFirst: false the existing delays and pauses stay, and leveling adds to them. Use that to keep a deliberate gap the user set by hand.
const { JsonProject } = require("./pkg-node/plen_wasm.js");

function unwrap(text) {
  const response = JSON.parse(text);
  if (!response.ok) {
    const { kind, code, message, path } = response.error;
    throw new Error(`${kind} ${code ?? ""}: ${message} (at ${path})`);
  }
  return response.result;
}

const data = {
  tasks: [
    { id: "A", start_date: "2026-08-10 08:00", duration: 2 },
    { id: "B", start_date: "2026-08-10 08:00", duration: 2 },
  ],
  resources: [{ id: "dev1" }],
  assignments: [
    { task: "A", resource: "dev1", value: 1 },
    { task: "B", resource: "dev1", value: 1 },
  ],
};

const project = new JsonProject("{}");
try {
  unwrap(project.solve(JSON.stringify(data), "{}"));
  unwrap(project.level("{}")); // B moves to Wednesday

  // Undo leveling on B.
  const changes = { tasks: { update: [{ id: "B", leveling_delay: 0, pauses: [] }] } };
  const report = unwrap(project.apply(JSON.stringify(changes)));
  if (report.needsResync) throw new Error("edit rejected; resync with solve(dataJson, ...)");

  const replanned = unwrap(project.solve(undefined, "{}"));
  for (const update of replanned.updates) {
    console.log(`${update.id}: ${update.start_date} .. ${update.end_date}`);
  }
  // dev1 is overallocated on Monday and Tuesday again.
  console.log((replanned.overallocations ?? []).some((o) => (o.windows ?? []).length > 0)); // true
} finally {
  project.free();
}

Output:

B: 2026-08-10 08:00 .. 2026-08-12 08:00
true

The leveling_delay update moves B's stored start back by the same amount, so B returns to Monday and the window over Monday and Tuesday comes back. To clear every task, put one such update per task with a delay or pauses into the same change set. The full recipe is in Clear leveling.