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
InputExceptionit's only the description, such asinvalid duration. The field is inPath, so read both when you show the error to a user. - On a
ProjectExceptionit's the kebab-case code (no-baseline,stale-baseline,needs-resync). Branch onCode, not on the text.
Input errors#
An InputException carries:
Kind, anInputErrorKind. Calls in this package always produceValidation.Parsecomes only from JSON decoding in the separatePlen.Wirepackage.Message, a short description such asinvalid durationorduplicate task id.Path, the offending field in its JSON (wire) spelling:tasks[0].duration,assignments[1].id,calendars[0].worktime.days,scope.resources. It'snullfor parse errors and for refusals that concern the whole state, such asSuggestAssignmentson 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,EndDateandDuration, or with all three when they don't agree - a dated constraint (
Snet,Fnlt, ...) without aConstraintDate - duplicate task, link or calendar ids (
1and"1"count as the same id) - an unknown
Parent, or a cycle in theParenthierarchy - a summary with
StartDate+Duration(a summary takes both dates or neither) - a negative
Duration, aPriorityabove 1000, a negativeLevelingDelay - an assignment to an unknown task or resource, or with
Value = 0 - a
Worktime.Daysarray 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
Pauseslists 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
InputExceptionwithKind == InputErrorKind.Validationfor a bad pool or requirement.Pathuses the same format as the core:pool.workers[0].resource,requirements[2].count,requirements[1].assignments[0]. - a
ProjectExceptionwithCode == ProjectError.NoBaselinewhen the project has no clean solved baseline. CallSolvebeforeSlots, including afterApply.
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);
Date overflow on summary-link graphs#
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.