Audience: Internal engineering + PM. 🟡 Mostly built, one honest fragility. The
scheduling stack is real and code-grounded, but it hard-couples schedule writes to a live
OpenProject and has one dependency-mirror path that can silently no-op. Both are called out
below. Cross-links: Jobs CC · Integrations.
Badges: 🟢 built & solid · 🟡 built but fragile / partial · ⚪ optional / best-effort · ❌ not built.
Every Job has a schedule made of tasks (ScheduleItem), grouped into phases, linked
by dependencies. The hub is the scheduler of record: it computes all task dates from
durations + the dependency graph (all four relation types) in
app/services/schedule_engine.py:36. It then pushes the resulting dates into OpenProject,
which keeps a shadow work package per task and returns authoritative computed dates that the
hub mirrors back. OpenProject is a backend, not the brain — the rest of the app never says
"work package," only Job/task/crew (app/integrations/openproject/adapter.py:1-12).
| Concern | Owner | Where |
|---|---|---|
| What the schedule should be (tasks, durations, dependency graph, all 4 relation types) | Hub | schedule_engine.py:36, models/schedule.py:98 |
| Computed/cascaded dates for FS-linked auto tasks | OpenProject (mirrored back) | adapter.py:259-272 |
| Phase/WBS rollup dates & progress (Summary-task parent) | OpenProject | adapter.py:115-148 |
| Hub-native sub-task rollup (Phase→Task→Sub-task, duration-weighted %) | Hub | services/schedule_rollup.py:24 |
Business fields: phase, trade, assignee, client_visible/sub_visible, confirmation, requires_permit_id, budget_cents |
Hub only (never sent to OP) | models/schedule.py:55-73 |
| Critical path | Hub (pure compute) | services/critical_path.py:37 |
| Time entries (mirror) | OpenProject | models/schedule.py:122 |
| Identity map (Job→Project, task→WP, dep→relation) | Hub | adapter.py:69-112 |
OpenProject natively understands only finish-to-start scheduling. So the hub computes SS/FF/SF
itself and pushes dates, keeping those successors manually scheduled in OP; only true FS links
become OP's auto-scheduling precedes relation, and everything else is drawn as a non-scheduling
relates link — a faithful visual shadow that never lets OP's scheduler fight the hub
(adapter.py:59-60).
The per-Job schedule tab lives at /portal/jobs/{id}?view=classic&tab=schedule. The Gantt is a
custom timeline (it replaced Frappe Gantt), built server-side in
app/portal/router.py:2085-2120:
portal/router.py:2078-2109; services/baseline.py). Re-baseline viaPOST /portal/jobs/{id}/baseline (app/routers/planning.py:282).critical_path.compute at portal/router.py:2096).schedule_viz.find_conflicts.Adjacent surfaces, all hub-owned in app/routers/planning.py:
/portal/planner) — a week grid of tasks by crew, drag-to-assign, plus an AIplanning.py:389-486).planning.py:225-276, engine at schedule_engine.py:113).planning.py:560), print view (planning.py:585), and a token-gatedplanning.py:512).planning.py:298, 607).planning.py:350),services/portfolio.py:29, overdue tasks across all jobs), and theA task is a ScheduleItem (app/models/schedule.py:18). Key fields: subject, start_date,
due_date, is_milestone, estimated_hours, percentage_done, status, phase, trade,
assignee_sub_id, plus the OpenProject mirror (openproject_wp_id, lock_version) and hub-native
hierarchy (parent_item_id, rollup_override).
Dependencies are ScheduleDependency (models/schedule.py:98). The row is hub-native first —
predecessor_item_id / successor_item_id UUID FKs — so it works even for un-synced schedules;
from_wp_id / to_wp_id / openproject_relation_id only mirror the OP relation once synced.
relation_type is one of FS / SS / FF / SF, lag in working days (±).
schedule_engine.compute (schedule_engine.py:36) is a working-day-aware forward pass:
| Type | Meaning | Rule |
|---|---|---|
| FS | finish → start | S starts lag+1 working days after P finishes |
| SS | start → start | S starts lag working days after P starts |
| FF | finish → finish | S finishes lag working days after P finishes |
| SF | start → finish | S finishes lag working days after P starts |
It's cycle-safe (topological pass, then input-order fallback), honours per-task "floors" so pinned
tasks keep their start while only successors cascade, and is shared by both templates (index-based
ids) and live jobs (UUID ids). recompute_job (schedule_engine.py:146) re-flows a live job and
returns the changed items for the OP push + crew notification.
An approved change order with schedule_impact_days shifts the not-yet-started tasks and extends
in-progress ones exactly once (idempotent, guarded by applied_to_schedule), then cascades, pushes
to OP, and notifies — app/services/co_schedule.py:20.
add_dependency mirror gapThere are two ways a ScheduleDependency row gets written, and they populate different
columns:
add_task_dependency (portal/router.py:4469) and theportal/router.py:4842) write rows with predecessor_item_id +successor_item_id set. _recompute_and_sync then calls mirror_relations to draw the OPportal/router.py:4578-4614). This path is correct.adapter.add_dependency (adapter.py:279) writes rows with only from_wp_id /to_wp_id / openproject_relation_id — it never sets predecessor_item_id /successor_item_id (adapter.py:312-316).The problem: mirror_relations filters on exactly the fields path #2 omits — it selects deps
where successor_item_id.in_(...) and predecessor_item_id.is_not(None)
(adapter.py:336-339). So a row created by add_dependency is invisible to the mirror (it
can never be re-mirrored if its OP relation is lost) and invisible to the hub engine, whose
job_dependencies applies the same predecessor_item_id.is_not(None) filter
(schedule_engine.py:133-143). In other words: dependencies created via add_dependency are not
seen by the scheduler of record, even though it created the OP relation directly.
Blast radius today is small — add_dependency is only called by demo/sample seeding
(services/sample_data.py:237). But it is a latent trap: any future caller that reaches for
adapter.add_dependency gets a dependency the hub engine ignores. Fix direction: have
add_dependency set the hub-native FKs (or delete it in favour of the UI path + mirror_relations).
Related subtlety:
critical_path.computekeys edges offfrom_wp_id/to_wp_id
(critical_path.py:61), which on hub-native rows are populated only aftermirror_relations
backfills them. If OP is unreachable, the arrows never mirror,from_wp_idstays null, and the
critical path silently sees no edges. Worth confirming in the open questions below.
app/integrations/openproject/adapter.py is the only module that speaks OpenProject. Writes are
idempotent via the IdentityMap:
sync_job_to_project — lazily creates the backing OP Project on first task pushadapter.py:81).push_schedule_item — creates/updates the work package, resolves the phase-summary parent,lockVersion back onto the hubadapter.py:151-276).mirror_relations / drop_dependency_relation — keep OP's arrows in sync with the hub graphadapter.py:324, 378).check_calendar_alignment — read-only drift report between OP's instance non-working days and theadapter.py:545).This is the headline risk. Creating or editing a task hard-fails HTTP 502 if OpenProject is
unreachable:
# app/routers/schedule.py:71-74 (identical guard on update at :100-101)
try:
push_schedule_item(db, item)
except OpenProjectError as exc:
raise HTTPException(status_code=502, detail=f"OpenProject error: {exc}") from exc
The hub row is committed first (schedule.py:69), so data isn't lost — but the API returns 502
and the caller sees a failure. Consequences:
/api/schedule create/edit into a 502, even though the_recompute_and_sync,co_schedule, and the proposal-acceptance push all wrap the OP call in best-effort try/except andportal/router.py:4590-4601, co_schedule.py:54-67,services/openproject_sync.py). Reads (GET /api/schedule/job/{id}) don't touch OP at allschedule.py:37). So the 502 is an inconsistency, not a platform-wide requirement — only the/api/schedule create/edit endpoints are hard-coupled.OpenProject webhooks are treated as a freshness hint only — the payload schema and signature are
officially undocumented and there's no delivery/retry guarantee. The inbound webhook verifies the
HMAC defensively and merely touches updated_at to mark the row stale; it does not apply the
change (app/routers/webhooks.py:20-53, integrations/openproject/webhooks.py).
The authoritative pull is reconcile_openproject (services/scheduler.py:260): for every
active/on-hold Job that has an OP project, it calls reconcile_project to pull OP's current dates
into the mirror (adapter.py:499), plus a once-per-cycle calendar-drift check. There's also an
on-demand POST /portal/jobs/{id}/schedule/refresh (portal/router.py:4440).
⚠️ Doc/impl mismatch worth knowing. The model and webhook docstrings say reconcile runs as a
"Celery, nightly" job (models/schedule.py:6-7,integrations/openproject/webhooks.py:5-6).
In reality there is no Celery — it's an in-app asyncio loop baked into the hub image,
digest_loop, running every 30 minutes (scheduler.py:25_CHECK_SECONDS = 1800,
dispatched atscheduler.py:171). One uvicorn worker → exactly one loop. So "nightly" reads
as ~48×/day, not once. Good in practice (fresher mirror); bad for anyone trusting the docstring.
| Model | Mirrors (OP) | Hub-native fields | File |
|---|---|---|---|
ScheduleItem |
work package | phase, trade, assignee, visibility, confirmation, permit gate, budget, sub-task tree | models/schedule.py:18 |
JobPhase |
Summary task | name, position | models/schedule.py:78 |
ScheduleDependency |
relation | predecessor/successor FKs, relation_type (FS/SS/FF/SF), lag | models/schedule.py:98 |
TimeEntry |
time entry | — | models/schedule.py:122 |
These are unresolved product/architecture calls, not bugs to file — surface them in design review.
schedule_engine.py, critical_path.py,schedule_rollup.py). OpenProject's remaining unique value is FS auto-cascade + phase rollup —schedule.py:71-74 makes /api/schedule create/edit failadd_dependency (the buggy path) isadd_dependency be fixed or deleted? It writes rows the hub engine andmirror_relations both ignore (adapter.py:312 vs adapter.py:338, schedule_engine.py:143).mirror_relations?scheduler.py:25,171). Should we (a)from_wp_id/to_wp_idcritical_path.py:61), which are backfilled only when mirror_relations succeeds. If OP ispredecessor_item_id/successor_item_id FKs so it never depends on OP?portfolio.py, planner) enough?adapter.py:545). Who watches that warning, and what's the remediation runbook when