Error handling#

This guide covers how Planning Engine reports failures in .NET: which problems throw, which come back as data inside a normal result, and how to get a live project working again after each kind.

How failures surface#

Planning Engine reports failures with exceptions. Every call is synchronous, so a failure is thrown from the call itself. There's no Task to inspect. If you run a long call with Task.Run, the same exception comes out of the await.

Every engine exception derives from PlenException, and it has two concrete subclasses:

Exception Meaning Project state after the exception
InputException The data or options you passed are invalid. Unchanged. The project keeps the previous accepted state and its cached result.
ProjectException The input may be fine, but the call doesn't fit the project's current state. Unchanged. You fix it by calling the right method first.

ProjectException.Code is a ProjectError value:

Code Message When it occurs
NoBaseline no-baseline Preview, LoadProfile, Evaluate, SuggestAssignments or Roster on a project that has no solved baseline, including right after Apply
StaleBaseline stale-baseline A BaselineId in the options or in SchedulingChanges doesn't name the current baseline
NeedsResync needs-resync Solve(null, …), Apply or Level after an Apply rejected an operation, until a full Solve(data, …) succeeds

Most of what goes wrong in a plan isn't an exception at all. Dependency cycles, constraint conflicts, dropped links, a planner that didn't converge and rejected Apply operations come back inside the result. An exception means the call itself couldn't run.

A few framework exceptions (ArgumentNullException and two others) cover misuse of the .NET types themselves. They're listed under Framework exceptions. Valid input throws something else in only one case.

Catching errors#

catch (PlenException) covers every engine error, including those from the Plen.Assign skill layer. Catch the subclasses first when you need to tell them apart:

using Plen;

var data = new ProjectData
{
    Tasks = [new TaskData(1) { StartDate = Moment.FromYmdHm(2026, 1, 5, 8, 0), Duration = 2.0 }],
};

try
{
    var result = Engine.Schedule(data, new SolveOptions());
    Console.WriteLine($"{result.Updates.Count} tasks moved");
}
catch (InputException e)
{
    Console.Error.WriteLine($"bad input at {e.Path}: {e.Message}");
}
catch (ProjectException e)
{
    Console.Error.WriteLine($"wrong project state: {e.Code}");
}

Message holds different things on the two subclasses:

  • On an InputException it's only the description, such as invalid duration. The field is in Path, so read both when you show the error to a user.
  • On a ProjectException it's the kebab-case code (no-baseline, stale-baseline, needs-resync). Branch on Code, not on the text.

Input errors#

An InputException carries:

  • Kind, an InputErrorKind. Calls in this package always produce Validation. Parse comes only from JSON decoding in the separate Plen.Wire package.
  • Message, a short description such as invalid duration or duplicate task id.
  • Path, the offending field in its JSON (wire) spelling: tasks[0].duration, assignments[1].id, calendars[0].worktime.days, scope.resources. It's null for parse errors and for refusals that concern the whole state, such as SuggestAssignments on a cyclic project.

Paths use the snake_case wire names, not the C# property names. TaskData.ConstraintDate on the first task is reported as tasks[0].constraint_date.

With the default Validation.Strict, Solve(data, …) and Engine.Schedule reject the input on the first problem they find. Strict validation rejects, among other things:

  • a leaf task with fewer than two of StartDate, EndDate and Duration, or with all three when they don't agree
  • a dated constraint (Snet, Fnlt, ...) without a ConstraintDate
  • duplicate task, link or calendar ids (1 and "1" count as the same id)
  • an unknown Parent, or a cycle in the Parent hierarchy
  • a summary with StartDate + Duration (a summary takes both dates or neither)
  • a negative Duration, a Priority above 1000, a negative LevelingDelay
  • an assignment to an unknown task or resource, or with Value = 0
  • a Worktime.Days array that doesn't have exactly 7 entries, and calendars with no working time

The full list per field is on the reference pages for ProjectData, TaskData and AssignmentData.

Reporting a validation error with its path#

using Plen;

var data = new ProjectData
{
    Tasks = [new TaskData(1) { StartDate = Moment.FromYmdHm(2026, 1, 5, 8, 0), Duration = -1.0 }],
};

try
{
    var result = Engine.Schedule(data, new SolveOptions());
    Console.WriteLine($"{result.Updates.Count} tasks moved");
}
catch (InputException e)
{
    // Prints "tasks[0].duration: invalid duration"
    Console.Error.WriteLine($"{e.Path ?? "<input>"}: {e.Message}");
}

A rejected replacement keeps the old state#

On a live Project, a Solve(data, …) that fails validation installs nothing. The project keeps its accepted baseline, and Solve(null, …) still returns the result you had before:

using Plen;

var options = new SolveOptions();
var project = new Project();
var first = project.Solve(new ProjectData { Tasks = [Leaf(1, 2.0)] }, options);

try
{
    project.Solve(new ProjectData { Tasks = [Leaf(2, -1.0)] }, options);
}
catch (InputException e)
{
    Console.Error.WriteLine($"replacement rejected at {e.Path}: {e.Message}");
}

// Still the first data set.
var again = project.Solve(null, options);
Console.WriteLine(again.Equals(first)); // True

static TaskData Leaf(Id id, double duration) =>
    new(id) { StartDate = Moment.FromYmdHm(2026, 1, 5, 8, 0), Duration = duration };

Project errors#

A ProjectException tells you the call came at the wrong moment. Each code has one recovery:

Code Recovery
NoBaseline Call Solve first. After Apply, call Solve(null, …) before Preview, LoadProfile, Evaluate or SuggestAssignments.
StaleBaseline The project was solved, edited or leveled since you read the token. Take BaselineId from the latest result and retry.
NeedsResync Send the full data again with Solve(data, …). Nothing else is accepted until that call succeeds.

Telling the codes apart#

Exception filters let each code get its own catch block:

using Plen;

// An unsolved project: prints "solve the project first".
Console.WriteLine(PreviewOrExplain(new Project(), new SchedulingChanges(), ""));

static string PreviewOrExplain(Project project, SchedulingChanges changes, string baselineId)
{
    try
    {
        var result = project.Preview(changes, new PreviewOptions { BaselineId = baselineId });
        return $"{result.Updates.Count} tasks would move";
    }
    catch (ProjectException e) when (e.Code == ProjectError.NoBaseline)
    {
        return "solve the project first";
    }
    catch (ProjectException e) when (e.Code == ProjectError.StaleBaseline)
    {
        return "the plan changed since you loaded it; refresh and try again";
    }
    catch (ProjectException e) when (e.Code == ProjectError.NeedsResync)
    {
        return "reload the full project";
    }
    catch (InputException e)
    {
        return $"invalid change at {e.Path}: {e.Message}";
    }
}

Preview checks both options.BaselineId and changes.BaselineId. Baseline tokens are opaque and unique to the process, so never store them across processes or compare them in tests.

Recovering from NeedsResync#

A rejected operation in Apply doesn't throw. It's listed in ApplyReport.Rejected and sets NeedsResync. From then on, Solve(null, …), Apply and Level throw a ProjectException with Code == ProjectError.NeedsResync, because your model and the project's model no longer agree. Resync with the full data you hold:

using Plen;

var data = new ProjectData
{
    Tasks = [new TaskData(1) { StartDate = Moment.FromYmdHm(2026, 1, 5, 8, 0), Duration = 2.0 }],
};
var project = new Project();
project.Solve(data, new SolveOptions());

// Task 999 doesn't exist: the operation is rejected.
var changes = new SchedulingChanges();
changes.Tasks.Update.Add(new TaskUpdate(999) { Duration = 3.0 });

var result = Commit(project, changes, data);
Console.WriteLine($"resynced, baseline {result.BaselineId}");

static ScheduleResult Commit(Project project, SchedulingChanges changes, ProjectData sourceOfTruth)
{
    var options = new SolveOptions();
    var report = project.Apply(changes);
    if (report.NeedsResync)
    {
        foreach (var rejected in report.Rejected)
            Console.Error.WriteLine($"rejected {rejected.Path}: {rejected.Message}");
        return project.Solve(sourceOfTruth, options);
    }
    return project.Solve(null, options);
}

Solve deep-copies the data it receives, so you can pass the same ProjectData object again. If the resync data itself fails validation, the InputException leaves the project in the NeedsResync state. Fix the data and call Solve(data, …) again. The recovery how-to covers this flow in an editor.

Results that are not errors#

These all come back in a normal return value. Check them on every result you show to a user:

Where What it means
ScheduleResult.Cycles Dependency cycles. A cyclic solve returns unchanged dates and empty Updates, and still installs a baseline.
ScheduleResult.Conflicts Conflict.ConstraintViolation (a link and a hard constraint disagree, the constraint wins) and Conflict.CalendarNonWorking
ScheduleResult.Notes Input the engine dropped (Note.DroppedLink, Note.DroppedTask, Note.DroppedAssignment) or fixed (Note.Repaired)
ScheduleResult.Converged false when the planner didn't reach a fixed point within Config.MaxIterations. The dates are still valid output.
ApplyReport.Rejected Operations that Apply refused. The other operations in the batch were still applied.
CandidateResult.Error An Evaluate candidate whose change set was rejected. That candidate has no metrics. The other candidates are scored normally.

Checking a schedule for problems#

Conflict and Note are closed unions, so read them with pattern matching:

using Plen;

var start = Moment.FromYmdHm(2026, 1, 5, 8, 0);
var data = new ProjectData
{
    Tasks =
    [
        new TaskData("a") { StartDate = start, Duration = 1.0 },
        new TaskData("b") { StartDate = start, Duration = 1.0 },
    ],
    // a -> b -> a: a dependency cycle, reported as data.
    Links =
    [
        new LinkData { Id = "l1", Source = "a", Target = "b" },
        new LinkData { Id = "l2", Source = "b", Target = "a" },
    ],
};

var result = Engine.Schedule(data, new SolveOptions());
foreach (var cycle in result.Cycles)
    Console.WriteLine($"cycle through tasks {string.Join(", ", cycle.Tasks)}");
foreach (var conflict in result.Conflicts)
{
    if (conflict is Conflict.ConstraintViolation v)
        Console.WriteLine($"task {v.Task}: required {v.Required}, got {v.Actual}");
}
if (!result.Converged)
    Console.WriteLine("planner hit MaxIterations");

Dependency cycles are accepted state. Cycles in the Parent hierarchy are different: they're invalid input and throw an InputException.

Repair instead of reject#

Validation.Strict suits data your own code produces. For data from a UI or an older store, Validation.Repair coerces it instead of throwing:

  • duplicate ids: the last record wins, at the first record's position
  • missing dates or durations are derived
  • in an inconsistent start/end/duration triple the dates win
  • dated constraints without a date are cleared
  • invalid Pauses lists and assignment slice values are cleared

Each fix shows up as a Note.Repaired with a Path and a Message, so you can show the user what changed. Repair mode still rejects an entirely undated project, and other problems still throw an InputException.

using Plen;

var first = new TaskData(1) { StartDate = Moment.FromYmdHm(2026, 1, 5, 8, 0), Duration = 1.0 };
var duplicate = first.DeepClone();
duplicate.Duration = 2.0;

var project = new Project(new ProjectOptions { Validation = Validation.Repair });
var result = project.Solve(new ProjectData { Tasks = [first, duplicate] }, new SolveOptions());
foreach (var note in result.Notes)
{
    if (note is Note.Repaired repaired)
        Console.WriteLine($"repaired {repaired.Path}: {repaired.Message}");
}

The messy input how-to shows the repair flow end to end.

Errors from SkillLayer.Slots#

The Plen.Assign package throws the same two exceptions as the core. SkillLayer.Slots throws:

  • an InputException with Kind == InputErrorKind.Validation for a bad pool or requirement. Path uses the same format as the core: pool.workers[0].resource, requirements[2].count, requirements[1].assignments[0].
  • a ProjectException with Code == ProjectError.NoBaseline when the project has no clean solved baseline. Call Solve before Slots, including after Apply.

A method that builds slots and passes them to SuggestAssignments needs only one set of catch blocks:

using Plen;
using Plen.Assign;

var pool = new Pool
{
    Workers = [new Worker { Resource = "dev1", Skills = [new Skill { Id = "csharp", Value = 1.0 }] }],
};
Requirement[] requirements = [new Requirement("backend", 1, [SkillMatch.AtLeast("csharp", 1.0)])];

try
{
    var result = Staff(new Project(), pool, requirements);
    Console.WriteLine($"{result.Steps.Count} steps");
}
catch (InputException e)
{
    Console.Error.WriteLine($"{e.Path}: {e.Message}");
}
catch (ProjectException e)
{
    // An unsolved project: prints "project state: no-baseline".
    Console.Error.WriteLine($"project state: {e.Message}");
}

static SuggestResult Staff(Project project, Pool pool, IReadOnlyList<Requirement> requirements)
{
    var slots = SkillLayer.Slots(project, pool, requirements);
    return project.SuggestAssignments(slots, new SuggestOptions());
}

Framework exceptions#

Outside the engine exceptions, the library throws standard .NET exceptions only for misuse of its types:

Exception When
ArgumentNullException A public method gets null for an argument that isn't declared nullable. Id.Str(null) throws it too.
ArgumentOutOfRangeException Execution.Parallel(threads) with threads <= 0, or Moment.ToDateTime() on a moment outside years 1-9999
InvalidOperationException Patch<T>.Value on a patch without a value, or Id.Key() of an infinite number

These point at a bug in the calling code, so you rarely catch them. Avoid them up front instead:

using Plen;

var update = new TaskUpdate(1) { Duration = 3.0 };

// TryGetValue never throws; update.StartDate.Value would, because the field is Missing.
if (update.StartDate.TryGetValue(out var startDate))
    Console.WriteLine($"new start {startDate}");
if (update.Duration.TryGetValue(out var duration))
    Console.WriteLine($"new duration {duration}");

// ToDateTime() throws outside years 1-9999; Minutes is always readable.
var moment = new Moment(long.MaxValue / 2);
Console.WriteLine(moment.Minutes);

One shape of valid input can throw. In block mode, summary-to-summary links combined with leaf links between the same summaries push each linked group later than the one before. On very large generated graphs the dates leave the supported range, and the solve throws InvalidOperationException with the message minute timestamp in range. It isn't a PlenException, so catch (PlenException) doesn't catch it.

Apart from this case, valid input never throws anything else. The library has no other exception path for problems in a plan.