fix(sessions): bound the merged assigned CLI set at the 200-row cap
The review asked for "the existing 200-row assigned cap across the final merged CLI set". The per-project budget was only tightened by an equal share of a 2000-row ceiling, so it bit past 10 assigned projects and left the review's own reproduction intact: 1,000 assigned conversations spread over 5 projects returned all 1,000 (2000 // 5 = 400, every project stayed under its own 200). Bound the merged assigned payload at CLI_PROJECT_ASSIGNED_CAP and spend that budget with a round-robin draw across the projects, newest first within each project, so bounding the payload cannot starve a quiet project the way a flat sessions[:200] truncation would (greptile P1). CLI_PROJECT_ASSIGNED_SCAN_CEILING is gone: 200 is strictly tighter than the model's 2000-row scan ceiling, so mirroring it here was dead. Its drift guard is replaced by a stronger one pinning the route cap to models.PROJECT_ASSIGNED_CLI_LIMIT and to <= the model scan ceiling.
This commit is contained in:
1 parent
76be369c54
commit
bc80d71177
2 files changed
+175
-102
No files matched your search
+85
-47
@@ -10488,29 +10488,73 @@ def _cli_visible_session_cap() -> int:
|
|||||||
return CLI_VISIBLE_SESSION_LIMIT
|
return CLI_VISIBLE_SESSION_LIMIT
|
||||||
|
|
||||||
|
|
||||||
# Per-project bound on project-assigned CLI rows in the FINAL MERGED payload.
|
# Bound on project-assigned CLI rows in the FINAL MERGED payload, across EVERY
|
||||||
# This is the only place that sees every source of assigned rows at once —
|
# project. This is the only place that sees every source of assigned rows at
|
||||||
# state.db's own bounded passes plus imported WebUI sidecars from all_sessions(),
|
# once — state.db's own bounded passes plus imported WebUI sidecars from
|
||||||
# which no model-side cap applies to — so it is the only place that can actually
|
# all_sessions(), which no model-side cap applies to — so it is the only place
|
||||||
# bound the assigned set (#6659 review finding 1).
|
# that can actually bound the assigned set (#6659 review finding 1).
|
||||||
|
#
|
||||||
|
# It has to bound the MERGED set, not one project: 200 rows x N projects grows
|
||||||
|
# with the project count, and 1,000 assigned conversations spread over 5 projects
|
||||||
|
# still returned all 1,000 — the exact reproduction from that finding. Pinned
|
||||||
|
# equal to models.PROJECT_ASSIGNED_CLI_LIMIT (itself resolved from the shared
|
||||||
|
# configurable sidebar window's ceiling on the model side) by
|
||||||
|
# test_route_merged_assigned_cap_is_the_existing_model_row_cap.
|
||||||
CLI_PROJECT_ASSIGNED_CAP = 200
|
CLI_PROJECT_ASSIGNED_CAP = 200
|
||||||
# Global ceiling on the assigned rows this cap will emit, mirroring
|
|
||||||
# models.PROJECT_ASSIGNED_CLI_SCAN_CEILING so the route can never hand back more
|
|
||||||
# assigned conversations than the model pass was allowed to recover. A flat
|
def _draw_assigned_cli_rows_fairly(
|
||||||
# per-project bound does NOT bound the payload: 200 rows x N projects grows with
|
rows_by_project: dict[str, list[int]], budget: int
|
||||||
# the project count, and 1,000 assigned rows spread over 5 projects still
|
) -> set[int]:
|
||||||
# returned all 1,000 — the exact reproduction from #6659 review finding 1.
|
"""Pick ``budget`` assigned row indices, spread fairly across the projects.
|
||||||
# Spelled as a literal because api.models is imported further down this module;
|
|
||||||
# pinned equal to the model constant by
|
``rows_by_project`` maps project id -> that project's row indices, newest
|
||||||
# test_route_project_ceiling_matches_the_model_constant.
|
first, keyed in order of each project's most recent assigned conversation
|
||||||
CLI_PROJECT_ASSIGNED_SCAN_CEILING = 2000
|
(``sessions`` is newest-first, so insertion order already is that order).
|
||||||
|
|
||||||
|
Each round hands one slot to every project that still has history left, so:
|
||||||
|
|
||||||
|
* the drawn set never exceeds ``budget`` — that is the whole point, a
|
||||||
|
per-project bound does not bound the payload (#6659 review finding 1);
|
||||||
|
* no single busy project can eat every slot, which a flat ``sessions[:200]``
|
||||||
|
truncation would do to whichever project sorts first — the starvation
|
||||||
|
greptile rejected as P1 on #6659;
|
||||||
|
* every project keeps at least one row whenever
|
||||||
|
``budget >= len(rows_by_project)``. Past that the bound wins: with more
|
||||||
|
assigned projects than slots, the ``budget`` most recently active projects
|
||||||
|
get one row each, because the review's number is the hard constraint.
|
||||||
|
|
||||||
|
Within a project the draw is newest-first, so what a chip loses is always the
|
||||||
|
oldest end of its own history.
|
||||||
|
"""
|
||||||
|
drawn: set[int] = set()
|
||||||
|
if budget <= 0 or not rows_by_project:
|
||||||
|
return drawn
|
||||||
|
queues = list(rows_by_project.values())
|
||||||
|
offsets = [0] * len(queues)
|
||||||
|
remaining = budget
|
||||||
|
while remaining > 0:
|
||||||
|
progressed = False
|
||||||
|
for position, project_rows in enumerate(queues):
|
||||||
|
offset = offsets[position]
|
||||||
|
if offset >= len(project_rows):
|
||||||
|
continue
|
||||||
|
drawn.add(project_rows[offset])
|
||||||
|
offsets[position] = offset + 1
|
||||||
|
remaining -= 1
|
||||||
|
progressed = True
|
||||||
|
if remaining <= 0:
|
||||||
|
break
|
||||||
|
if not progressed:
|
||||||
|
# Every project is exhausted — the whole assigned set fits.
|
||||||
|
break
|
||||||
|
return drawn
|
||||||
|
|
||||||
|
|
||||||
def _cap_recent_cli_sessions(
|
def _cap_recent_cli_sessions(
|
||||||
sessions: list[dict],
|
sessions: list[dict],
|
||||||
cli_cap: int | None = None,
|
cli_cap: int | None = None,
|
||||||
project_cap: int = CLI_PROJECT_ASSIGNED_CAP,
|
project_cap: int = CLI_PROJECT_ASSIGNED_CAP,
|
||||||
project_ceiling: int = CLI_PROJECT_ASSIGNED_SCAN_CEILING,
|
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Cap the default CLI list while retaining project-addressable rows.
|
"""Cap the default CLI list while retaining project-addressable rows.
|
||||||
|
|
||||||
@@ -10525,43 +10569,39 @@ def _cap_recent_cli_sessions(
|
|||||||
to a project silently shortens everyone's sidebar to 17 rows. Resolved
|
to a project silently shortens everyone's sidebar to 17 rows. Resolved
|
||||||
lazily from the shared configurable window (HERMES_WEBUI_VISIBLE_SESSION_LIMIT),
|
lazily from the shared configurable window (HERMES_WEBUI_VISIBLE_SESSION_LIMIT),
|
||||||
never a second hard-coded 20.
|
never a second hard-coded 20.
|
||||||
* ``project_cap`` assigned conversations PER PROJECT stay in the payload so
|
* ``project_cap`` assigned conversations IN TOTAL, across every project, stay
|
||||||
the project chip can reveal them, marked ``default_hidden`` once the recent
|
in the payload so the project chips can reveal them, marked
|
||||||
window is full. Past that bound they are dropped: keeping assigned rows past
|
``default_hidden`` once the recent window is full. Past that bound they are
|
||||||
the *recent* cap is the fix, keeping them past *all* bounds just trades a
|
dropped: keeping assigned rows past the *recent* cap is the fix, keeping
|
||||||
vanishing session for a stalled sidebar.
|
them past *all* bounds just trades a vanishing session for a stalled
|
||||||
|
sidebar.
|
||||||
|
|
||||||
That per-project budget is an EQUAL share of ``project_ceiling``
|
That assigned budget is spent by a fair round-robin draw across the projects
|
||||||
(``ceiling // projects``, never below 1) once enough projects exist to exceed
|
(see ``_draw_assigned_cli_rows_fairly``) instead of by truncating the merged
|
||||||
it, which is what makes the merged payload bounded no matter how many
|
list, so bounding the payload cannot starve a quiet project (greptile P1 on
|
||||||
projects a profile has while keeping a project's allowance independent of how
|
#6659). ``project_cap <= 0`` disables the assigned bound entirely.
|
||||||
loud its neighbours are. Same shape as the model-side share of
|
|
||||||
``PROJECT_ASSIGNED_CLI_SCAN_CEILING``, and for the same reason a flat global
|
|
||||||
cap is not usable here: it would leave every project past it permanently
|
|
||||||
unreachable on every rebuild (greptile P1 on #6659).
|
|
||||||
"""
|
"""
|
||||||
if cli_cap is None:
|
if cli_cap is None:
|
||||||
cli_cap = _cli_visible_session_cap()
|
cli_cap = _cli_visible_session_cap()
|
||||||
if cli_cap <= 0:
|
if cli_cap <= 0:
|
||||||
return sessions
|
return sessions
|
||||||
# The share denominator has to be known before the first assigned row is
|
# Group the assigned rows per project first: the draw has to weigh the
|
||||||
# charged against it, so count the distinct assigned projects up front.
|
# projects against each other, which a single forward pass cannot do.
|
||||||
assigned_projects = {
|
rows_by_project: dict[str, list[int]] = {}
|
||||||
project_id
|
for index, session in enumerate(sessions):
|
||||||
for session in sessions
|
if not _is_cli_session_for_settings(session):
|
||||||
if _is_cli_session_for_settings(session)
|
continue
|
||||||
and (project_id := str(session.get("project_id") or "").strip())
|
project_id = str(session.get("project_id") or "").strip()
|
||||||
}
|
if project_id:
|
||||||
effective_project_cap = project_cap
|
rows_by_project.setdefault(project_id, []).append(index)
|
||||||
if project_cap > 0 and project_ceiling > 0 and assigned_projects:
|
drawn = (
|
||||||
effective_project_cap = min(
|
None if project_cap <= 0
|
||||||
project_cap, max(1, project_ceiling // len(assigned_projects))
|
else _draw_assigned_cli_rows_fairly(rows_by_project, project_cap)
|
||||||
)
|
)
|
||||||
kept = []
|
kept = []
|
||||||
recent_seen = 0
|
recent_seen = 0
|
||||||
unassigned_seen = 0
|
unassigned_seen = 0
|
||||||
assigned_seen: dict[str, int] = {}
|
for index, session in enumerate(sessions):
|
||||||
for session in sessions:
|
|
||||||
if _is_cli_session_for_settings(session):
|
if _is_cli_session_for_settings(session):
|
||||||
project_id = str(session.get("project_id") or "").strip()
|
project_id = str(session.get("project_id") or "").strip()
|
||||||
if not project_id:
|
if not project_id:
|
||||||
@@ -10570,9 +10610,7 @@ def _cap_recent_cli_sessions(
|
|||||||
continue
|
continue
|
||||||
recent_seen += 1
|
recent_seen += 1
|
||||||
else:
|
else:
|
||||||
seen = assigned_seen.get(project_id, 0) + 1
|
if drawn is not None and index not in drawn:
|
||||||
assigned_seen[project_id] = seen
|
|
||||||
if effective_project_cap > 0 and seen > effective_project_cap:
|
|
||||||
continue
|
continue
|
||||||
if recent_seen >= cli_cap:
|
if recent_seen >= cli_cap:
|
||||||
session = dict(session)
|
session = dict(session)
|
||||||
|
|||||||
@@ -12,9 +12,11 @@ swing to the opposite failure, so these tests pin BOTH edges of the contract:
|
|||||||
is followed by project-scoped, per-project-budgeted queries (greptile P1);
|
is followed by project-scoped, per-project-budgeted queries (greptile P1);
|
||||||
* the follow-up is COMPLETE — every starved project is served, with no fixed
|
* the follow-up is COMPLETE — every starved project is served, with no fixed
|
||||||
per-build project cap that would permanently skip the rest (greptile P1);
|
per-build project cap that would permanently skip the rest (greptile P1);
|
||||||
* the recovered set is BOUNDED — per project, counting logical conversations —
|
* the recovered set is BOUNDED — the FINAL MERGED assigned payload never exceeds
|
||||||
including assigned rows that arrive as imported WebUI sidecars and therefore
|
the 200-row assigned cap, counting logical conversations, including assigned
|
||||||
never pass through any state.db cap;
|
rows that arrive as imported WebUI sidecars and therefore never pass through
|
||||||
|
any state.db cap — and that bound is spent by a fair per-project draw, so
|
||||||
|
bounding the payload does not starve a quiet project;
|
||||||
* assigned rows do not spend the unassigned sidebar quota: 20 unique unassigned
|
* assigned rows do not spend the unassigned sidebar quota: 20 unique unassigned
|
||||||
logical conversations survive after lineage/sidecar dedup;
|
logical conversations survive after lineage/sidecar dedup;
|
||||||
* a deleted or cross-profile ``project_id`` cannot hide a session — it resolves
|
* a deleted or cross-profile ``project_id`` cannot hide a session — it resolves
|
||||||
@@ -291,11 +293,57 @@ def test_route_cap_bounds_imported_sidecar_assigned_rows(fake_hermes_home):
|
|||||||
assert not any("default_hidden" in row for row in sidecars)
|
assert not any("default_hidden" in row for row in sidecars)
|
||||||
|
|
||||||
|
|
||||||
def test_route_cap_bounds_each_project_independently(fake_hermes_home):
|
def _assigned_project_counts(rows):
|
||||||
|
"""project_id -> kept row count, for the assigned rows only."""
|
||||||
|
counts: dict[str, int] = {}
|
||||||
|
for row in rows:
|
||||||
|
project_id = row.get("project_id")
|
||||||
|
if project_id:
|
||||||
|
counts[project_id] = counts.get(project_id, 0) + 1
|
||||||
|
return counts
|
||||||
|
|
||||||
|
|
||||||
|
def test_route_cap_bounds_the_reviewers_five_project_reproduction(fake_hermes_home):
|
||||||
|
"""The review's literal reproduction: 1,000 assigned rows over 5 projects.
|
||||||
|
|
||||||
|
A PER-PROJECT 200 is not a bound on the payload — every one of the five
|
||||||
|
projects stayed under its own 200, so 1,000 rows went in and 1,000 came out,
|
||||||
|
which is exactly what review finding 1 reported. The review asked for "the
|
||||||
|
existing 200-row assigned cap across the final MERGED CLI set", so the bound
|
||||||
|
is on the whole assigned payload, not on one project.
|
||||||
|
"""
|
||||||
|
project_count = 5
|
||||||
|
per_project = 200
|
||||||
|
rows = []
|
||||||
|
for project in range(project_count):
|
||||||
|
rows.extend(
|
||||||
|
{
|
||||||
|
"session_id": f"project-{project}-{index:04d}",
|
||||||
|
"is_cli_session": True,
|
||||||
|
"project_id": f"project-{project}",
|
||||||
|
}
|
||||||
|
for index in range(per_project)
|
||||||
|
)
|
||||||
|
assert len(rows) == 1000
|
||||||
|
|
||||||
|
kept = routes._cap_recent_cli_sessions(rows)
|
||||||
|
|
||||||
|
assert len(kept) == routes.CLI_PROJECT_ASSIGNED_CAP == 200
|
||||||
|
# Bounded by a FAIR draw, not by truncating the head of the list: the rows
|
||||||
|
# are grouped per project, so a flat `sessions[:200]` would have handed
|
||||||
|
# project-0 every slot and left the other four unreachable — the starvation
|
||||||
|
# greptile rejected as P1.
|
||||||
|
counts = _assigned_project_counts(kept)
|
||||||
|
assert len(counts) == project_count
|
||||||
|
assert set(counts.values()) == {routes.CLI_PROJECT_ASSIGNED_CAP // project_count}
|
||||||
|
|
||||||
|
|
||||||
|
def test_route_cap_splits_the_merged_budget_fairly_between_projects(fake_hermes_home):
|
||||||
"""A busy project must not evict another project's history (greptile P1).
|
"""A busy project must not evict another project's history (greptile P1).
|
||||||
|
|
||||||
The bound is per project, so a profile with more than
|
The merged budget is drawn round-robin, newest first WITHIN each project, so
|
||||||
``CLI_PROJECT_ASSIGNED_CAP`` assigned conversations in TOTAL keeps them all.
|
two equally deep projects get an equal share and each keeps its newest
|
||||||
|
conversations rather than an arbitrary slice.
|
||||||
"""
|
"""
|
||||||
rows = []
|
rows = []
|
||||||
for project in ("project-a", "project-b"):
|
for project in ("project-a", "project-b"):
|
||||||
@@ -310,27 +358,26 @@ def test_route_cap_bounds_each_project_independently(fake_hermes_home):
|
|||||||
|
|
||||||
kept = routes._cap_recent_cli_sessions(rows)
|
kept = routes._cap_recent_cli_sessions(rows)
|
||||||
|
|
||||||
counts: dict[str, int] = {}
|
half = routes.CLI_PROJECT_ASSIGNED_CAP // 2
|
||||||
for row in kept:
|
assert _assigned_project_counts(kept) == {"project-a": half, "project-b": half}
|
||||||
counts[row["project_id"]] = counts.get(row["project_id"], 0) + 1
|
assert len(kept) == routes.CLI_PROJECT_ASSIGNED_CAP
|
||||||
assert counts == {
|
# Newest first within each project: the draw keeps the head of each queue.
|
||||||
"project-a": routes.CLI_PROJECT_ASSIGNED_CAP,
|
kept_ids = {row["session_id"] for row in kept}
|
||||||
"project-b": routes.CLI_PROJECT_ASSIGNED_CAP,
|
for project in ("project-a", "project-b"):
|
||||||
}
|
assert f"{project}-0000" in kept_ids
|
||||||
assert len(kept) > routes.CLI_PROJECT_ASSIGNED_CAP
|
assert f"{project}-{half - 1:04d}" in kept_ids
|
||||||
|
assert f"{project}-{half:04d}" not in kept_ids
|
||||||
|
|
||||||
|
|
||||||
def test_route_cap_bounds_the_whole_merged_assigned_set(fake_hermes_home):
|
def test_route_cap_never_starves_a_project_with_more_projects_than_slots(
|
||||||
"""A per-project bound alone is not a bound on the payload (finding 1).
|
fake_hermes_home,
|
||||||
|
):
|
||||||
|
"""Every project with an assigned session keeps at least one row.
|
||||||
|
|
||||||
``CLI_PROJECT_ASSIGNED_CAP`` rows times N projects grows with the project
|
40 projects x 200 rows still fits the merged cap because 200 // 40 = 5 — the
|
||||||
count: 1,000 assigned conversations spread over 5 projects reproduced the
|
draw shrinks each project's slice instead of dropping whole projects.
|
||||||
review's original "1,000 rows in, 1,000 rows out" exactly, because every
|
|
||||||
project stayed under its own 200. The equal share of
|
|
||||||
``CLI_PROJECT_ASSIGNED_SCAN_CEILING`` is what actually bounds the merged set.
|
|
||||||
"""
|
"""
|
||||||
project_count = 40
|
project_count = 40
|
||||||
per_project = 200
|
|
||||||
rows = []
|
rows = []
|
||||||
for project in range(project_count):
|
for project in range(project_count):
|
||||||
rows.extend(
|
rows.extend(
|
||||||
@@ -339,31 +386,23 @@ def test_route_cap_bounds_the_whole_merged_assigned_set(fake_hermes_home):
|
|||||||
"is_cli_session": True,
|
"is_cli_session": True,
|
||||||
"project_id": f"project-{project:02d}",
|
"project_id": f"project-{project:02d}",
|
||||||
}
|
}
|
||||||
for index in range(per_project)
|
for index in range(200)
|
||||||
)
|
)
|
||||||
assert len(rows) > routes.CLI_PROJECT_ASSIGNED_SCAN_CEILING
|
|
||||||
|
|
||||||
kept = routes._cap_recent_cli_sessions(rows)
|
kept = routes._cap_recent_cli_sessions(rows)
|
||||||
|
|
||||||
assert len(kept) <= routes.CLI_PROJECT_ASSIGNED_SCAN_CEILING
|
counts = _assigned_project_counts(kept)
|
||||||
# Bounded by an EQUAL share, not by dropping whole projects: every project
|
assert len(kept) == routes.CLI_PROJECT_ASSIGNED_CAP
|
||||||
# keeps a slice, or this would be the starvation greptile P1 rejected.
|
|
||||||
counts: dict[str, int] = {}
|
|
||||||
for row in kept:
|
|
||||||
counts[row["project_id"]] = counts.get(row["project_id"], 0) + 1
|
|
||||||
share = routes.CLI_PROJECT_ASSIGNED_SCAN_CEILING // project_count
|
|
||||||
assert len(counts) == project_count
|
assert len(counts) == project_count
|
||||||
assert set(counts.values()) == {share}
|
assert min(counts.values()) >= 1
|
||||||
# The share only bites once it is tighter than the per-project cap.
|
assert set(counts.values()) == {routes.CLI_PROJECT_ASSIGNED_CAP // project_count}
|
||||||
assert share < routes.CLI_PROJECT_ASSIGNED_CAP
|
|
||||||
|
|
||||||
|
|
||||||
def test_route_cap_share_does_not_shrink_below_the_per_project_cap(fake_hermes_home):
|
def test_route_cap_keeps_every_assigned_row_under_the_merged_cap(fake_hermes_home):
|
||||||
"""Few projects must keep the full 200 — the ceiling is a ceiling, not a quota.
|
"""Negative control: the draw is inert while the assigned set fits.
|
||||||
|
|
||||||
Negative control for the test above: with the ceiling comfortably wider than
|
Without this, "bounded at 200" could just as well be a blanket shrink. Two
|
||||||
``project_count * CLI_PROJECT_ASSIGNED_CAP`` the share is inert, so the
|
projects with 60 conversations each keep all 120.
|
||||||
bounding above is attributable to the ceiling and not to a blanket shrink.
|
|
||||||
"""
|
"""
|
||||||
rows = []
|
rows = []
|
||||||
for project in ("project-a", "project-b"):
|
for project in ("project-a", "project-b"):
|
||||||
@@ -373,31 +412,27 @@ def test_route_cap_share_does_not_shrink_below_the_per_project_cap(fake_hermes_h
|
|||||||
"is_cli_session": True,
|
"is_cli_session": True,
|
||||||
"project_id": project,
|
"project_id": project,
|
||||||
}
|
}
|
||||||
for index in range(250)
|
for index in range(60)
|
||||||
)
|
)
|
||||||
|
|
||||||
kept = routes._cap_recent_cli_sessions(rows)
|
kept = routes._cap_recent_cli_sessions(rows)
|
||||||
|
|
||||||
counts: dict[str, int] = {}
|
assert _assigned_project_counts(kept) == {"project-a": 60, "project-b": 60}
|
||||||
for row in kept:
|
assert len(kept) == 120 < routes.CLI_PROJECT_ASSIGNED_CAP
|
||||||
counts[row["project_id"]] = counts.get(row["project_id"], 0) + 1
|
|
||||||
assert counts == {
|
|
||||||
"project-a": routes.CLI_PROJECT_ASSIGNED_CAP,
|
|
||||||
"project-b": routes.CLI_PROJECT_ASSIGNED_CAP,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_route_project_ceiling_matches_the_model_constant():
|
def test_route_merged_assigned_cap_is_the_existing_model_row_cap():
|
||||||
"""The route literal must not drift from the model-side scan ceiling.
|
"""The route's merged bound must stay the 200-row cap the review named.
|
||||||
|
|
||||||
Same reasoning as ``test_background_source_exclusion_literal_matches_the_constant``:
|
``api.models`` is imported below this cap in ``api/routes.py``, so the value
|
||||||
the value is duplicated because ``api.models`` is imported below this cap in
|
is spelled as a literal there and only a test can keep the two honest. The
|
||||||
``api/routes.py``, so only a test can keep the two honest. If they diverge the
|
merged bound must also never exceed what the model recovery pass can deliver,
|
||||||
route could hand back more assigned rows than the model pass ever recovers.
|
or the route would advertise a budget nothing can fill.
|
||||||
"""
|
"""
|
||||||
|
assert routes.CLI_PROJECT_ASSIGNED_CAP == models.PROJECT_ASSIGNED_CLI_LIMIT == 200
|
||||||
assert (
|
assert (
|
||||||
routes.CLI_PROJECT_ASSIGNED_SCAN_CEILING
|
routes.CLI_PROJECT_ASSIGNED_CAP
|
||||||
== models.PROJECT_ASSIGNED_CLI_SCAN_CEILING
|
<= models.PROJECT_ASSIGNED_CLI_SCAN_CEILING
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user