Repository navigation
bug(scheduler): TaskComplexity aliases collapse — every non-simple workflow silently gets the security-scan priority multiplier #13806
Description
Discovered while fixing #13699/#13730 (the orchestrator planning path).
Finding
TaskComplexity declares 5 members but only 2 distinct values:
# autobot-backend/autobot_types.py
class TaskComplexity(Enum):
SIMPLE = "simple"
COMPLEX = "complex"
RESEARCH = "complex" # <- alias of COMPLEX
INSTALL = "complex" # <- alias of COMPLEX
SECURITY_SCAN = "complex" # <- alias of COMPLEXIn Python, Enum members sharing a value become aliases of the same object, not distinct
members. TaskComplexity.RESEARCH is TaskComplexity.COMPLEX is True. The comment calls them
"legacy values for backward compatibility (map to COMPLEX)", which is accurate — but several
call sites are written as though they are distinguishable, and they are not.
This is a live scheduler bug, not just cosmetics
autobot-backend/workflow_scheduler.py:340-346 builds a per-complexity priority multiplier
(Issue #376, "use named constants"):
complexity_multiplier = {
TaskComplexity.SIMPLE: WorkflowConfig.COMPLEXITY_SIMPLE, # 0.8
TaskComplexity.RESEARCH: WorkflowConfig.COMPLEXITY_RESEARCH, # 1.0
TaskComplexity.INSTALL: WorkflowConfig.COMPLEXITY_INSTALL, # 1.1
TaskComplexity.COMPLEX: WorkflowConfig.COMPLEXITY_COMPLEX, # 1.2
TaskComplexity.SECURITY_SCAN: WorkflowConfig.COMPLEXITY_SECURITY_SCAN, # 1.3
}Four of those five keys are the same object, so the literal collapses to two entries and
last write wins:
$ # reproduction with the same member definitions
dict written with 3 distinct keys -> 1 entry
TaskComplexity.RESEARCH is COMPLEX: True
lookup RESEARCH gives: <the last-written value>
Because SECURITY_SCAN is written last, every non-simple workflow is scheduled with the
security-scan multiplier of 1.3:
| Complexity | Intended multiplier | Actual |
|---|---|---|
| SIMPLE | 0.8 | 0.8 ✅ |
| RESEARCH | 1.0 | 1.3 |
| INSTALL | 1.1 | 1.3 |
| COMPLEX | 1.2 | 1.3 |
| SECURITY_SCAN | 1.3 | 1.3 ✅ |
The per-complexity tuning #376 added has therefore never taken effect. Reordering the dict
literal would silently change scheduling priority across the board — which is the hazard here:
the behaviour depends on source order, invisibly.
Second consequence: template metadata is lossy
workflow_templates/{research,sysadmin,security,analysis}.py set complexity=TaskComplexity.RESEARCH
/ .INSTALL / .SECURITY_SCAN on ~10 templates. All of them persist and compare as COMPLEX,
so no consumer can distinguish a research template from an install template by complexity.
Third: it makes tests look broader than they are
orchestration/plan_steps_e2e_test.py iterated [SIMPLE, RESEARCH, INSTALL, COMPLEX] as four
"complexity levels" — really SIMPLE plus COMPLEX three times. Corrected in #13730, with a
comment, but the underlying trap remains for the next author.
Options
- Collapse honestly — delete the three aliases, migrate the ~13 call sites to
COMPLEX, and drop the now-unreachableCOMPLEXITY_RESEARCH/INSTALL/SECURITY_SCAN
constants. Behaviour is then what it already is, just visible. Scheduling changes only if
the collapsed multiplier is chosen deliberately. - Make them real — give each a distinct value so the Code Smell: Replace 537 magic numbers with named constants #376 tuning works as designed. This
changes scheduling behaviour and needs the persisted-value migration considered
(templates storecomplexity). - Keep the aliases but fix the scheduler to key on something non-collapsing, and add a guard
test. Least churn, but leaves the trap for the next reader.
Recommendation: 1, unless the per-complexity tuning is actually wanted, in which case 2
with a migration. Either way the dict must stop depending on source order.
Acceptance criteria
- Decide between collapsing the aliases and making them distinct; record the reasoning.
-
workflow_scheduler.pyno longer builds a dict whose keys silently merge. - A test asserts the multiplier actually applied per complexity, so a source-order change
cannot alter scheduling unnoticed. - If aliases are retained, a guard test asserts no dict/
matchis keyed on more than one
alias of the same member.
Activity
style(scheduler): drop an unused import from the collapse guard (#13806)
style: drop the unused pytest import and reformat (#13806)
mrveiss commentedon Aug 10, 2026
Guard landed ahead of the decision — PR #13881
The collapse-vs-distinct choice is still yours; it changes scheduling priority for every non-simple workflow, so I have not pre-empted it. What #13881 does remove is the silence around it.
Reproduced against the current tree:
Tests only, no behaviour change:
COMPLEXITY_SECURITY_SCANfor everything else), so reordering the literal fails a test instead of retuning the schedulerTaskComplexitykeys silently merge — verified by planting oneworkflow_scheduler.pyis exempted by name; removing that exemption is part of resolving this issueOne thing worth knowing before you pick
"Collapse honestly" is not behaviour-neutral in the obvious way. Writing the natural form:
{TaskComplexity.SIMPLE: COMPLEXITY_SIMPLE, TaskComplexity.COMPLEX: COMPLEXITY_COMPLEX}moves every non-simple workflow from 1.3 → 1.2. Preserving today's behaviour requires writing
COMPLEXITY_SECURITY_SCANas the COMPLEX multiplier — correct, but it reads like a mistake, so it needs a comment saying it is deliberate.So the real fork is:
complexity.My recommendation is 2 if you want the constant names to mean what they say and are comfortable with a one-notch priority drop, otherwise 1. 3 only if per-complexity tuning is genuinely wanted — it is the only option that changes relative ordering between workflow kinds.
Labelling
needs-decisionand moving on; the guard makes whichever you pick safe to execute.test(scheduler): pin the applied complexity multipliers so the alias …
mrveiss commentedon Aug 10, 2026
Guard landed — PR #13881 merged
Verified in the base branch:
Three tests now hold the line:
COMPLEXITY_SECURITY_SCANfor everything else. That assertion looks wrong deliberately: it records what runs, not what the source reads as. Reordering the dict literal now fails a test instead of silently retuning the scheduler.TaskComplexitykeys merge (verified by planting one)workflow_scheduler.pystays exempt by name — removing that exemption is part of resolving this issue.Still needs your decision
Nothing here changed behaviour. The three options and the recommendation are in the earlier comment; the short version is that "collapse honestly" is not behaviour-neutral — writing the natural
{SIMPLE: COMPLEXITY_SIMPLE, COMPLEX: COMPLEXITY_COMPLEX}moves every non-simple workflow from 1.3 to 1.2. Whichever you pick, the guard makes it safe to execute and impossible to get wrong silently.Staying open with
needs-decision.mrveiss commentedon Sep 6, 2026
Removing
needs-decision: the tree answers which option applies.autobot-backend/autobot_types.py:19-21still hasRESEARCH = INSTALL = SECURITY_SCAN = "complex", andtests/orchestration/test_task_complexity_aliases.pypins that collapse (assert TaskComplexity.RESEARCH is TaskComplexity.COMPLEX) rather than fixing it. Since nothing distinguishes the three today, Option 1 — collapse honestly — is what the code already does; the work is making the declaration say so.fix(core): collapse the TaskComplexity aliases, wire in the call-grap…
fix(tests): two CI failures from this branch's own new fixtures (#13806…
fix(analytics,tests): three review findings — two false graph edges a…
mrveiss commentedon Oct 5, 2026
Closed by PR #17965, merged as
176ce734d8. Verified against mergedmain.mainTaskComplexityhas 2 members; the five-key literal named three Enum aliases ofCOMPLEXssot_constants.pycarries exactlyCOMPLEXITY_SIMPLE = 0.8andCOMPLEXITY_COMPLEX = 1.3, replacing five1.3, not the1.2the source appeared to declareThe number is the finding
COMPLEXITY_SECURITY_SCANwas written last into the old five-key literal and overwroteCOMPLEXITY_COMPLEX, because three of the five keys were Enum aliases resolving to the same member. So every non-simple workflow has always been scheduled at 1.3 while the source read as1.2.Collapsing the aliases was held to zero behaviour change, which means keeping 1.3. Re-tuning it is a scheduling decision and is deliberately not part of this issue.
A test that proved nothing, found in review
The multiplier test built a mapping from the same two constants it then asserted on, and never called
_calculate_priority_score. It passed whatever the scheduler actually applied — including1.0, or the two factors swapped.It now compares the ratio of two real scores with every other input held constant. A future-dated
scheduled_timekeeps the overdue bonus out, since that is time-dependent and is added before the complexity factor multiplies.Mutation-verified rather than asserted — with
complexity_factor = 1.0:That is the same shape as the defect this issue is about: a declaration and the code that consumes it drifting apart, with nothing comparing them. The guard now compares them.
fix(core): collapse the TaskComplexity aliases, wire in the call-grap…