Build Scheduler: Plans, Worker Pools, Resource Limits and Critical Paths
HardAI-assisted coding · AI-Assisted Feature Work
Build Scheduler: Plans, Worker Pools, Resource Limits and Critical Paths
You've joined the developer-productivity team that owns the build scheduler: the service that takes a pipeline of tasks with dependencies and works out the order to run them and how long the pipeline will take on a given number of workers. The planner has two known bugs, and three capabilities are queued behind them. All times are whole seconds.
Codebase
| File | What it does |
|---|---|
scheduler/api.py | run(operations) entry point and the Dispatcher (each route declares its min and max argument count) |
scheduler/service.py | SchedulerService: adding tasks, planning, simulation, limits, analysis |
scheduler/graph.py | TaskGraph: the append-only DAG and its reverse (dependents) index |
scheduler/planner.py | topological_order and the urgency sort key |
scheduler/queueing.py | ReadyQueue: a heap of ready tasks ordered by a sort key |
scheduler/simulator.py | simulate stub for Part 2 |
scheduler/limits.py | GroupLimits stub for Part 3 |
scheduler/analysis.py | CriticalPath stub for Part 4 |
scheduler/models.py | The Task dataclass |
scheduler/stats.py | Dashboard summary numbers (not used by the operations below) |
scheduler/validation.py, config.py, errors.py | Helpers, constants, error codes |
tests/ | Existing unit and end-to-end tests (some currently fail) |
How your code is run
run(operations: list[list]) -> list executes the operations in order against a fresh scheduler and returns one result per operation. Every operation is [name, *args]. Errors are returned as "error: <code>" strings, never raised.
| Operation | Arguments | Returns | Errors (in this order) |
|---|---|---|---|
add_task | task_id, duration, priority, deps | true | invalid arguments, invalid duration, task exists, unknown dependency |
plan | none | list of task ids in execution order | none |
simulate | workers | schedule object (Part 2) | invalid workers |
set_limit | group, limit | true (Part 3) | invalid arguments, invalid limit |
earliest_finish | task_id | integer seconds (Part 4) | task not found |
critical_path | none | list of task ids (Part 4) | none |
- An unknown operation returns
"error: unknown operation"; a wrong number of arguments returns"error: invalid arguments". - A task has an id (non-empty string, case-sensitive, compared as plain strings so
"t10"sorts before"t2"), an integerduration >= 1, an integerpriority(a larger number is more urgent, may be negative) anddeps, a list of ids.invalid argumentsmeans a wrong type (priority,depsnot a list of non-empty strings, etc.); adurationthat is not an integer>= 1isinvalid duration. - A task may only depend on tasks that were added earlier (otherwise
unknown dependency, which also covers depending on itself), so the graph never has a cycle. A dependency listed more than once counts once. planreturns every task exactly once, dependencies first. Among tasks that are ready at the same time, the one with the larger priority goes first; equal priorities are ordered by task id. ("Ready" means all of its dependencies are already in the plan.)
Worked example
[["add_task", "compile", 5, 1, []],
["add_task", "lint", 2, 5, []],
["add_task", "test", 8, 3, ["compile"]],
["add_task", "package", 3, 3, ["compile", "lint"]],
["plan"]]returns
[true, true, true, true, ["lint", "compile", "package", "test"]]lint (priority 5) beats compile (priority 1). After compile, both test and package are ready with priority 3, and package sorts before test.
Part 1 — Fix the planner (SCHED-190, SCHED-203)
Goal: plan must include every task and honor priorities.
Reports:
- "A task that lists the same dependency twice (our YAML generator sometimes does that) is missing from the plan entirely, and so is everything that depends on it."
- "Low-priority work is being scheduled ahead of urgent work when both are ready."
Rules
plancontains every task exactly once, whatever way its dependencies were written.- Among simultaneously ready tasks, larger
priorityfirst, then smaller task id.
tests/test_planner.py::test_more_urgent_ready_task_goes_first and ::test_task_with_a_repeated_dependency_is_still_planned currently fail.
Part 2 — Simulate a worker pool (SCHED-214)
Goal: simulate(workers) predicts when each task starts and finishes if workers identical workers execute the pipeline.
Rules
workersmust be an integer>= 1, elseerror: invalid workers.- Time starts at
0. A worker runs one task at a time, without interruption, for exactly itsduration. A task may start only when all of its dependencies have finished (a task starting at the moment its last dependency finishes is fine). - At every moment, in this order: (a) all tasks finishing at that moment complete and release their dependents; (b) each idle worker, one after another, takes the most urgent ready task (same order as
plan: larger priority, then smaller id). A worker never waits while a task is ready. - The result is
{"makespan": int, "start": {task_id: int}, "finish": {task_id: int}}covering every task.makespanis the latest finish (0for an empty graph, with emptystartandfinish).
Example (the pipeline above)
["simulate", 2]returns
{"makespan": 13,
"start": {"lint": 0, "compile": 0, "package": 5, "test": 5},
"finish": {"lint": 2, "compile": 5, "package": 8, "test": 13}}With one worker the makespan is 18 (lint, compile, package, test back to back).
Part 3 — Resource groups with concurrency limits (SCHED-298)
Goal: some tasks share a scarce resource (a GPU, a license server). A group can be limited so only a few of its tasks run at once.
Rules
add_tasktakes an optional fifth argumentgroup: a non-empty string ornull. The existing four-argument form keeps working and means no group (invalid argumentsfor any other value).set_limit(group, limit)sets the maximum number of tasks of that group running at the same moment.limitmust be an integer>= 1, elseerror: invalid limit; a non-string or emptygroupisinvalid arguments. Setting it again replaces the limit. A group with no limit, and tasks with no group, are unlimited. Limits can be set before or after tasks are added; they apply to every latersimulate.- In
simulate, when an idle worker picks the next task (rule 3b of Part 2), tasks whose group is already at its limit are skipped, not dropped: the worker takes the next most urgent ready task instead, and the skipped task stays ready for later. A task started earlier in the same moment counts toward its group, and a task finishing at that moment has already freed its slot. planand Part 2 behavior for tasks without limits do not change.
Example
[["add_task", "compile", 5, 1, []],
["add_task", "unit", 4, 3, ["compile"], "gpu"],
["add_task", "integ", 6, 2, ["compile"], "gpu"],
["add_task", "docs", 2, 1, ["compile"]],
["set_limit", "gpu", 1],
["simulate", 3]]returns true five times (four add_task, one set_limit), then
{"makespan": 15,
"start": {"compile": 0, "unit": 5, "docs": 5, "integ": 9},
"finish": {"compile": 5, "unit": 9, "docs": 7, "integ": 15}}At time 5 integ is more urgent than docs but its group is full, so docs takes the third worker.
Part 4 (stretch) — Critical path queries on large pipelines (SCHED-377)
Goal: capacity planning needs the theoretical minimum time for any task, instantly, while pipelines with thousands of tasks keep growing.
Rules
earliest_finish(task_id)is the earliest time the task can finish with unlimited workers and ignoring group limits: itsdurationplus the largestearliest_finishamong its dependencies (0if it has none). Unknown id:error: task not found.critical_path()returns the longest chain of tasks (sum of durations) as ids, first to last. It ends at the task with the greatestearliest_finish(ties: smaller id) and walks backwards, at each step choosing the dependency with the greatestearliest_finish(ties: smaller id). An empty graph returns[].- Queries can be interleaved with
add_taskand asked many times; hidden inputs contain pipelines where recomputing every path per query is far too slow.
Example (the pipeline above): earliest_finish("test") is 13, earliest_finish("package") is 8, and critical_path() is ["compile", "test"].
Notes
graph.py,queueing.pyandvalidation.pyare known-good, andstats.pyis unused by the operations.- You may use the AI assistant freely. Later parts must not break earlier ones; the hidden tests re-check earlier behavior as well.
- Parts 1-3 done well is a strong result; Part 4 is a stretch.
Example 1
- Input:
- [["add_task","compile",5,1,[]],["add_task","lint",2,5,[]],["add_task","test",8,3,["compile"]],["add_task","package",3,3,["compile","lint"]],["plan"]]
- Output:
- [true,true,true,true,["lint","compile","package","test"]]
- Explanation
- lint has the highest priority among the two initially ready tasks. After compile, test and package tie on priority, and package sorts first by id.
Example 2
- Input:
- [... the same four tasks ..., ["simulate",2]]
- Output:
- {"makespan":13,"start":{"lint":0,"compile":0,"package":5,"test":5},"finish":{"lint":2,"compile":5,"package":8,"test":13}}
- Explanation
- Both workers start immediately. package waits for compile at time 5, when both package and test start.
Example 3
- Input:
- [... compile, unit(gpu), integ(gpu), docs, ["set_limit","gpu",1], ["simulate",3]]
- Output:
- {"makespan":15,"start":{"compile":0,"unit":5,"docs":5,"integ":9},"finish":{"compile":5,"unit":9,"docs":7,"integ":15}}
- Explanation
- Only one gpu task may run at a time, so integ is skipped at time 5 in favor of docs and starts when unit finishes.
Guidelines
- Python 3 standard library only
- Durations and times are whole seconds; durations are at least 1
- A task can only depend on tasks added before it, so the graph is always acyclic
- Task ids are compared as plain strings ("t10" sorts before "t2")
- Every operation returns exactly one value; errors are returned as "error: <code>" strings, never raised
- Part 4 inputs make recomputing paths per query exponentially slow