Scheduling a Project#
This guide covers how to run the scheduler. It explains when to use the one-shot schedule function and when to keep a Project. It also covers what the solve options change, how forward and backward planning place tasks, and what "converged" means in a result.
How scheduling works#
There are two entry points, and they run the same planner:
schedule(data, &options)takes aProjectDatasnapshot, schedules it once and returns aScheduleResult. Nothing is kept after the call. It's a shortcut forProject::default().solve(Some(data), &options).Project::solve(data, &options)does the same work on a long-livedProject. The project keeps the scheduled dates as its accepted baseline, so later calls can edit, preview, level and evaluate against it.
Every solve follows the same steps. The engine imports the snapshot into its own normalized model and validates it. It builds the scheduling graph from links and summaries, checks for dependency cycles and resolves the scope (the whole project, or one branch with subtree). Then it runs the planner until the dates stop changing. The result lists only what moved.
A few rules shape what you get back:
- Updates are sparse.
result.updatesholds oneMutationper task whose dates changed, with only the changed fields set. Write them back into your own model. To get every task's dates, setinclude_unchanged. - Problems are data, not errors. Conflicts, cycles, dropped input and non-convergence come back inside
ScheduleResult.Errmeans invalid input (Error::Input) or a call that doesn't fit the project's state (Error::Project). See Error handling. - Some tasks never move. Tasks with
auto_scheduling: false, unscheduled tasks and completed tasks (use_progresswithprogress == 1) stay where they are. That's how you lock work in place. There are no cancellation hooks. - Calls are synchronous and CPU-only. There's no I/O, clock or async runtime. In an async server, run large solves on a worker thread or in
spawn_blocking.
One-shot scheduling#
Use schedule in batch jobs, request handlers and scripts that schedule a snapshot and throw it away.
use ;
Task 2 has to wait for task 1, so it's the only update: task 2: 2026-08-12 08:00 .. 2026-08-17 08:00. Task 1 is already where it belongs and isn't reported. The default "global" calendar works Mon-Fri, 08:00-12:00 and 13:00-17:00, which is why the dates skip the weekend.
schedule takes the data by value and returns a plain ScheduleResult, not an Arc. It fails only with Error::Input, when validation rejects the snapshot.
Solving a project#
Keep a Project when the plan lives on: an editor backend with one project per open document, or a service that applies edits over time.
use Arc;
use ;
The two forms of solve do different jobs:
solve(Some(data), ..)validates the replacement data, installs it, schedules it and accepts the dates as the new baseline. Use it on the first load and to resync the whole project. If validation fails, you getError::Inputand the previous state and cached result stay as they were.solve(None, ..)schedules the current state. After anapplyit plans the edits. Tasks the edit touched (task 1 above) are reported too, even when the planner leaves them where the edit put them. On a clean project with the same options it returns the cached result without planning again.
The cache key is the whole SolveOptions value. Calling solve(None, ..) with different options replans from the accepted dates. That doesn't change the data, so the baseline_id stays the same unless a task moves.
solve returns Arc<ScheduleResult> because the project keeps the same result in its cache. After a rejected apply operation, solve(None, ..) fails with ProjectError::NeedsResync until you send the full data again with solve(Some(data), ..). Editing is covered in Data Updates.
Solve options#
SolveOptions changes what a solve schedules and what it reports. The defaults schedule the whole project and return only the moves.
use ;
include_unchangedalso reports tasks that didn't move, each withstart_date,end_dateanddurationset. The result alone is then the full normalized date list, which is what an editor needs on first load. Unscheduled tasks are never reported. See Load all task dates.reasonsadds aReasonto each update: pushed by a predecessor link (Asap), pulled by a successor (Alap), moved with its summary block (SummaryTranslation), placed by its constraint (Constraint) or kept at its preferred date (Preferred).analyticsasks for the critical path, slack, or both. They're off by default. See Critical path and slack.subtreelimits the run to one task and what depends on it, likegantt.autoSchedule(id, inclusive).Subtree::new(id)setsinclusive: true, so the root task is scheduled too. The subtree applies to this call only. A scoped solve accepts only its own moves, and a later unscoped solve picks up the rest. An unknown or unscheduled subtree task is anError::Inputatsubtree.task, and the cached result is kept. See Reschedule a branch.
With subtree and include_unchanged together, the result holds every task in the subtree (with their ancestors) plus the out-of-subtree tasks that changed or were touched by an edit.
level and preview don't inherit these options: level solves with defaults first, and preview takes its own PreviewOptions.
Forward and backward planning#
By default the planner works forward: each task starts as early as its links allow. Set config.schedule_from_end to plan backward instead: each task ends as late as its successors allow, and the project is anchored at its end.
use ;
Task 2 has no successor, so project_end caps it and it ends at 2026-08-28 17:00. Task 1 ends where task 2 starts, on 2026-08-25 17:00.
The project bounds act only on tasks at the open ends of the graph:
project_start(forward) applies to tasks without predecessors. WithGapBehavior::Compressit pulls them onto the project start. WithGapBehavior::Preserveit only raises tasks that start earlier and leaves later ones at their dates. For a task with predecessors it's a hard floor only whenapply_constraintsis on.project_end(backward) caps tasks without successors, in either gap mode. Without it, such a task keeps its current end.
A task whose constraint points the other way gets a secondary pass in the opposite direction. In a forward project with apply_constraints, an Alap task moves as late as its successors allow:
use ;
install waits for build and starts on 2026-08-14 08:00. order moves from Monday to 2026-08-13 08:00, so it ends right when install starts.
Leveling is forward-only: Project::level rejects a project with schedule_from_end: true (Error::Input at config.schedule_from_end). Constraint types and summary behavior are covered in Links, constraints and summaries.
Determinism and convergence#
The same input and options always give the same result. The engine reads no clock and uses no randomness. It iterates in input order and topological order, never in hash order. That makes results safe to cache, diff and compare in tests.
The one exception is baseline_id. Tokens are opaque and may differ between runs for the same data, so never compare them across runs or store them. Take the current one from the latest solve result.
The planner runs passes until the dates stop changing. result.converged tells you whether it reached that point within config.max_iterations passes:
- An unscoped run converges in two passes by design. The second pass reproduces the first.
- A scoped run (
subtree) can need more passes, because tasks outside the scope feed into it. max_iterationsdefaults to 8 and must be at least 1.0is rejected as anError::Input.
use ;
converged: false isn't an error, and the result is still valid.
A dependency cycle stops the run before planning. Dates stay unchanged, updates is empty and cycles lists each unique cycle. For everything else a result carries, see Reading results.