diff --git a/README.md b/README.md
index 64af43f..17447ad 100644
--- a/README.md
+++ b/README.md
@@ -780,6 +780,11 @@ Worker публикует heartbeat в общей SQLite БД. Активный
"search": 2,
"graphql": 100
},
+ "priority_one_headroom": {
+ "core": 1200,
+ "search": 12,
+ "graphql": 1000
+ },
"costs": {
"insights": {"core": 1200, "search": 10, "graphql": 500},
"skill": {"core": 3750, "search": 20, "graphql": 2000},
@@ -1051,6 +1056,15 @@ GraphQL-запросом, обычно стоимостью в один GraphQL
нельзя обещать ни одному запуску, а `costs` — консервативную верхнюю оценку
расхода каждого маршрута. Допуск вычисляется как
`remaining - резервы выполняющихся задач - cost маршрута >= minimum_remaining`.
+Необязательный точный вектор `priority_one_headroom` сохраняет поверх аварийного
+остатка запас для задач PromptPilot с приоритетом `1`: для задач приоритетов
+`2`–`10` он прибавляется к `minimum_remaining`, а задача приоритета `1` может его
+использовать. Полное обновление статистики считается низкоприоритетной работой и
+тоже сохраняет этот запас. Ненулевой `priority_one_headroom` требует полной
+таблицы `costs` во всех включённых профилях с тем же GitHub token; при общем
+token PromptPilot покомпонентно применяет максимальный запас из этих профилей.
+Это admission-резерв для срочного запуска, а не гарантия неизменности живого
+лимита GitHub во время уже работающих задач.
Резерв хранится в общей SQLite БД, привязан к точной попытке задачи и снимается
в `finally`, поэтому два worker-потока могут безопасно выполнять дешёвые
REVIEW/MERGE одновременно, не расходуя один и тот же остаток дважды. Полный
diff --git a/promptpilot/pipeline_insights.py b/promptpilot/pipeline_insights.py
index eb92b4d..218395e 100644
--- a/promptpilot/pipeline_insights.py
+++ b/promptpilot/pipeline_insights.py
@@ -319,6 +319,12 @@ def _github_budget_policy(profile: dict) -> dict | None:
minimum_remaining = _minimum_remaining(
raw.get("minimum_remaining", {}), "minimum_remaining",
_DEFAULT_GITHUB_BUDGET_MINIMUM)
+ priority_one_headroom_raw = raw.get("priority_one_headroom")
+ priority_one_headroom = (
+ {resource: 0 for resource in _DEFAULT_GITHUB_BUDGET_MINIMUM}
+ if priority_one_headroom_raw is None else
+ _strict_budget_vector(
+ priority_one_headroom_raw, "priority_one_headroom"))
configured_costs = raw.get("costs")
costs = None
if configured_costs is not None:
@@ -341,9 +347,13 @@ def _github_budget_policy(profile: dict) -> dict | None:
configured_costs[route], f"costs.{route}")
for route in _GITHUB_BUDGET_ROUTES
}
+ if any(priority_one_headroom.values()) and costs is None:
+ raise ValueError(
+ "github_budget.priority_one_headroom требует github_budget.costs")
return {
"minimum_remaining": minimum_remaining,
+ "priority_one_headroom": priority_one_headroom,
"costs": costs,
"reset_grace_seconds": _bounded_int(
raw, "reset_grace_seconds", 60, 0, 3600),
@@ -362,14 +372,54 @@ def _github_budget_policy(profile: dict) -> dict | None:
def _with_shared_budget_floor(policy: dict) -> dict:
"""Use the strongest hard reserve of every profile sharing this account."""
floor = dict(policy["minimum_remaining"])
+ priority_one_headroom = dict(policy["priority_one_headroom"])
for configured_profile in _profiles().values():
candidate = _github_budget_policy(configured_profile)
if candidate is None or candidate["lease_scope"] != policy["lease_scope"]:
continue
for resource, value in candidate["minimum_remaining"].items():
floor[resource] = max(floor[resource], value)
+ for resource, value in candidate["priority_one_headroom"].items():
+ priority_one_headroom[resource] = max(
+ priority_one_headroom[resource], value)
selected = dict(policy)
selected["minimum_remaining"] = floor
+ selected["priority_one_headroom"] = priority_one_headroom
+ if any(priority_one_headroom.values()) and selected.get("costs") is None:
+ raise ValueError(
+ "общий github_budget.priority_one_headroom требует "
+ "github_budget.costs в каждом профиле с тем же GitHub token")
+ return selected
+
+
+def _budget_policy_for_admission_priority(policy: dict,
+ priority: int | None) -> dict:
+ """Keep configured quota headroom available to priority-1 work.
+
+ The headroom is added to the ordinary hard floor only for lower-priority
+ attempts. Priority 1 can consume it, while every other priority leaves it
+ available for a MERGE or another urgent wake-up that appears later.
+ """
+ selected = dict(policy)
+ headroom = dict(policy.get("priority_one_headroom") or {
+ resource: 0 for resource in policy["minimum_remaining"]})
+ selected["priority_one_headroom"] = headroom
+ selected["base_minimum_remaining"] = dict(policy["minimum_remaining"])
+ selected["priority_headroom_applied"] = False
+ selected["admission_priority"] = None
+ if not any(headroom.values()):
+ return selected
+ if type(priority) is not int or not 1 <= priority <= 10:
+ raise ValueError(
+ "admission priority is unavailable for GitHub priority headroom")
+ selected["admission_priority"] = priority
+ if priority == 1:
+ return selected
+ selected["minimum_remaining"] = {
+ resource: policy["minimum_remaining"][resource] + headroom[resource]
+ for resource in policy["minimum_remaining"]
+ }
+ selected["priority_headroom_applied"] = True
return selected
@@ -420,6 +470,20 @@ def _projected_post_reservation(
return projected
+def _priority_budget_metadata(policy: dict) -> dict:
+ resources = policy["minimum_remaining"]
+ base = policy.get("base_minimum_remaining") or resources
+ headroom = policy.get("priority_one_headroom") or {
+ resource: 0 for resource in resources}
+ return {
+ "base_minimum_remaining": dict(base),
+ "priority_one_headroom": dict(headroom),
+ "priority_headroom_applied": bool(
+ policy.get("priority_headroom_applied")),
+ "admission_priority": policy.get("admission_priority"),
+ }
+
+
def _budget_blocked_summary(item: dict, active_reservations: int) -> str:
"""Explain signed admission arithmetic without implying a GitHub value."""
actual_remaining = item.get(
@@ -460,6 +524,7 @@ def _budget_denied(policy: dict, *, state: str, reason: str,
"lease_scope": policy["lease_scope"],
"budget_route": policy.get("budget_route"),
"status_revision": status_revision,
+ **_priority_budget_metadata(policy),
}
@@ -627,13 +692,14 @@ def _evaluate_github_budget(policy: dict, limits: dict | None, *,
"blocked_resources": [], "lease_scope": policy["lease_scope"],
"budget_route": policy.get("budget_route"),
"status_revision": status_revision,
+ **_priority_budget_metadata(policy),
}
@contextmanager
def _github_scan_admission(profile: dict, purpose: str,
*, profile_id: str | None = None,
- budget_route: str | None = None):
+ budget_route: str | None = None, task=None):
"""Admit one expensive scan and hold its cross-process reservation."""
now = time.time()
try:
@@ -641,6 +707,13 @@ def _github_scan_admission(profile: dict, purpose: str,
if policy is not None:
policy = _with_shared_budget_floor(policy)
policy = _budget_policy_for_route(policy, budget_route)
+ if task is not None:
+ policy = _budget_policy_for_admission_priority(
+ policy, getattr(task, "priority", None))
+ elif budget_route == "insights":
+ # Full queue refreshes are useful but never more urgent than
+ # an already queued P1 integration task.
+ policy = _budget_policy_for_admission_priority(policy, 10)
except (TypeError, ValueError) as exc:
fallback = {
"minimum_remaining": dict(_DEFAULT_GITHUB_BUDGET_MINIMUM),
@@ -832,6 +905,8 @@ def _reserve_execution_admission(
return admission
policy = _with_shared_budget_floor(policy)
policy = _budget_policy_for_route(policy, budget_route)
+ policy = _budget_policy_for_admission_priority(
+ policy, getattr(task, "priority", None))
if not policy.get("cost_accounting"):
# Profiles without route costs keep the original one-shot floor
# admission; they neither re-read limits nor create reservations.
@@ -2522,7 +2597,8 @@ def execution_route(task, fallback_prompt: str, working_dir: str | None = None,
}
with _github_scan_admission(
profile, f"pipeline task {profile_id}/{queue.get('id')}",
- profile_id=profile_id, budget_route="skill") as admission:
+ profile_id=profile_id, budget_route="skill",
+ task=task) as admission:
if not admission.get("allowed"):
return _budget_defer_route(
admission, profile_id, profile, queue, mode="skill")
@@ -2553,7 +2629,8 @@ def execution_route(task, fallback_prompt: str, working_dir: str | None = None,
}
with _github_scan_admission(
profile, f"pipeline task {profile_id}/{queue.get('id')}",
- profile_id=profile_id, budget_route="skill") as admission:
+ profile_id=profile_id, budget_route="skill",
+ task=task) as admission:
if not admission.get("allowed"):
return _budget_defer_route(
admission, profile_id, profile, queue, mode="skill")
@@ -2572,7 +2649,8 @@ def execution_route(task, fallback_prompt: str, working_dir: str | None = None,
command = _tool_command(execution, stage)
with _github_scan_admission(
profile, f"pipeline preflight {profile_id}/{stage}",
- profile_id=profile_id, budget_route="tool_preflight") as admission:
+ profile_id=profile_id, budget_route="tool_preflight",
+ task=task) as admission:
if not admission.get("allowed"):
return _budget_defer_route(
admission, profile_id, profile, queue)
diff --git a/promptpilot/static/index.html b/promptpilot/static/index.html
index cb494af..230bb97 100644
--- a/promptpilot/static/index.html
+++ b/promptpilot/static/index.html
@@ -2704,6 +2704,7 @@
План этапов
const spendableBudget = githubBudgetState.spendable_before_route || {};
const projectedBudget = githubBudgetState.projected_post_reservation || {};
const budgetFloor = githubBudgetState.minimum_remaining || {};
+ const priorityHeadroom = githubBudgetState.priority_one_headroom || {};
const ledgerKnown = githubBudgetState.ledger_state !== 'unavailable';
const reservationCount = ledgerKnown
? Number(githubBudgetState.active_reservations || 0).toLocaleString()
@@ -2713,6 +2714,11 @@ План этапов
const value = projectedBudget[name] && projectedBudget[name][field];
return value == null ? '—' : Number(value).toLocaleString();
};
+ const hasPriorityHeadroom = ['core', 'search', 'graphql']
+ .some(name => Number(priorityHeadroom[name] || 0) > 0);
+ const priorityHeadroomNote = hasPriorityHeadroom
+ ? `Резерв для приоритета PromptPilot 1: REST ${budgetNumber(priorityHeadroom, 'core')} · Search ${budgetNumber(priorityHeadroom, 'search')} · GraphQL ${budgetNumber(priorityHeadroom, 'graphql')} · задачи приоритетов 2–10 и полное обновление статистики оставляют этот запас
`
+ : '';
const rateObserved = data.github_rate_limit_observed_at
? `измерено ${timeAgo(data.github_rate_limit_observed_at)}`
: 'на момент снимка очереди';
@@ -2726,6 +2732,7 @@ План этапов
${reservationCount} · REST −${ledgerKnown ? budgetNumber(reservedBudget, 'core') : '—'}
прогноз после активных резервов (не фактический GitHub remaining): REST доступно ${projectedNumber('core', 'available')}, дефицит ${projectedNumber('core', 'deficit')} · Search доступно ${projectedNumber('search', 'available')}, дефицит ${projectedNumber('search', 'deficit')} · GraphQL доступно ${projectedNumber('graphql', 'available')}, дефицит ${projectedNumber('graphql', 'deficit')}
можно выдать до hard reserve: REST ${budgetNumber(spendableBudget, 'core')} · Search ${budgetNumber(spendableBudget, 'search')} · GraphQL ${budgetNumber(spendableBudget, 'graphql')} · reserve REST ${budgetNumber(budgetFloor, 'core')}${budgetProblem ? ` · ${esc(budgetProblem)}` : ''}
+ ${priorityHeadroomNote}
` : '';
const coreLimit = githubLimits.core;
const coreShare = coreLimit && coreLimit.limit ? coreLimit.remaining / coreLimit.limit : null;
diff --git a/tests/test_github_budget.py b/tests/test_github_budget.py
index a28044e..1727638 100644
--- a/tests/test_github_budget.py
+++ b/tests/test_github_budget.py
@@ -180,6 +180,51 @@ def test_cost_schema_requires_every_known_route_and_exact_integer_vectors():
}
+def test_priority_one_headroom_defaults_to_zero_and_accepts_exact_vector():
+ legacy_policy = pipeline_insights._github_budget_policy(
+ _profile_with_costs())
+ configured = _profile_with_costs()
+ configured["github_budget"]["priority_one_headroom"] = {
+ "core": 1200, "search": 12, "graphql": 1000,
+ }
+
+ policy = pipeline_insights._github_budget_policy(configured)
+
+ assert legacy_policy["priority_one_headroom"] == {
+ "core": 0, "search": 0, "graphql": 0,
+ }
+ assert policy["priority_one_headroom"] == {
+ "core": 1200, "search": 12, "graphql": 1000,
+ }
+
+
+@pytest.mark.parametrize("invalid", [
+ [],
+ {"core": 1200, "search": 12},
+ {"core": 1200, "search": 12, "graphql": 1000, "other": 0},
+ {"core": True, "search": 12, "graphql": 1000},
+ {"core": "1200", "search": 12, "graphql": 1000},
+ {"core": 1200.0, "search": 12, "graphql": 1000},
+ {"core": -1, "search": 12, "graphql": 1000},
+])
+def test_priority_one_headroom_fails_closed_on_invalid_vector(invalid):
+ profile = _profile_with_costs()
+ profile["github_budget"]["priority_one_headroom"] = invalid
+
+ with pytest.raises(ValueError):
+ pipeline_insights._github_budget_policy(profile)
+
+
+def test_nonzero_priority_one_headroom_requires_route_costs():
+ profile = _profile()
+ profile["github_budget"]["priority_one_headroom"] = {
+ "core": 1, "search": 0, "graphql": 0,
+ }
+
+ with pytest.raises(ValueError, match="priority_one_headroom"):
+ pipeline_insights._github_budget_policy(profile)
+
+
@pytest.mark.parametrize(
"case",
[
@@ -220,9 +265,15 @@ def test_shared_github_scope_uses_strongest_profile_hard_reserve(
isolated_db, monkeypatch):
low = _profile_with_costs()
high = _profile_with_costs()
+ low["github_budget"]["priority_one_headroom"] = {
+ "core": 1200, "search": 3, "graphql": 400,
+ }
high["github_budget"]["minimum_remaining"] = {
"core": 900, "search": 3, "graphql": 700,
}
+ high["github_budget"]["priority_one_headroom"] = {
+ "core": 800, "search": 12, "graphql": 1000,
+ }
monkeypatch.setattr(
pipeline_insights, "_profiles",
lambda: {"low": low, "high": high})
@@ -233,6 +284,9 @@ def test_shared_github_scope_uses_strongest_profile_hard_reserve(
assert policy["minimum_remaining"] == {
"core": 900, "search": 3, "graphql": 700,
}
+ assert policy["priority_one_headroom"] == {
+ "core": 1200, "search": 12, "graphql": 1000,
+ }
monkeypatch.setattr(
pipeline_insights, "_github_rate_limits",
lambda: _limits(core=1000, search=30, graphql=5000))
@@ -250,6 +304,107 @@ def test_shared_github_scope_uses_strongest_profile_hard_reserve(
}
+def test_shared_headroom_fails_closed_for_profile_without_route_costs(
+ monkeypatch):
+ protected = _profile_with_costs()
+ protected["github_budget"]["priority_one_headroom"] = {
+ "core": 1200, "search": 12, "graphql": 1000,
+ }
+ legacy = _profile()
+ monkeypatch.setattr(
+ pipeline_insights, "_profiles",
+ lambda: {"protected": protected, "legacy": legacy})
+
+ with pytest.raises(ValueError, match="каждом профиле"):
+ pipeline_insights._with_shared_budget_floor(
+ pipeline_insights._github_budget_policy(legacy))
+
+
+def test_priority_one_can_use_headroom_while_lower_priority_is_deferred():
+ profile = _profile_with_costs(core=500)
+ profile["github_budget"]["priority_one_headroom"] = {
+ "core": 600, "search": 0, "graphql": 0,
+ }
+ base = pipeline_insights._budget_policy_for_route(
+ pipeline_insights._github_budget_policy(profile), "skill")
+
+ priority_one = pipeline_insights._budget_policy_for_admission_priority(
+ base, 1)
+ lower_priority = pipeline_insights._budget_policy_for_admission_priority(
+ base, 2)
+ p1_decision = pipeline_insights._evaluate_github_budget(
+ priority_one, _limits(core=1100), now=1000)
+ lower_decision = pipeline_insights._evaluate_github_budget(
+ lower_priority, _limits(core=1100), now=1000)
+
+ assert p1_decision["allowed"] is True
+ assert p1_decision["minimum_remaining"]["core"] == 100
+ assert p1_decision["base_minimum_remaining"]["core"] == 100
+ assert p1_decision["priority_one_headroom"]["core"] == 600
+ assert p1_decision["priority_headroom_applied"] is False
+ assert p1_decision["admission_priority"] == 1
+
+ assert lower_decision["allowed"] is False
+ assert lower_decision["state"] == "low"
+ assert lower_decision["minimum_remaining"]["core"] == 700
+ assert lower_decision["base_minimum_remaining"]["core"] == 100
+ assert lower_decision["priority_one_headroom"]["core"] == 600
+ assert lower_decision["priority_headroom_applied"] is True
+ assert lower_decision["admission_priority"] == 2
+
+
+def test_execution_route_applies_priority_one_headroom_before_dispatch(
+ isolated_db, monkeypatch):
+ profile = _profile_with_costs(core=500)
+ profile["github_budget"]["priority_one_headroom"] = {
+ "core": 600, "search": 0, "graphql": 0,
+ }
+ monkeypatch.setattr(
+ pipeline_insights, "_profiles", lambda: {"example": profile})
+ monkeypatch.setattr(
+ pipeline_insights, "_github_rate_limits",
+ lambda: _limits(core=1100))
+
+ def task(priority):
+ return SimpleNamespace(
+ series_id=1, series_title="Example - REVIEW",
+ prompt="Example - REVIEW", priority=priority)
+
+ priority_one = pipeline_insights.execution_route(
+ task(1), "Example - REVIEW")
+ lower_priority = pipeline_insights.execution_route(
+ task(2), "Example - REVIEW")
+
+ assert priority_one["action"] == "prompt"
+ assert lower_priority["action"] == "defer"
+ assert lower_priority["github_budget"]["minimum_remaining"]["core"] == 700
+ assert lower_priority["github_budget"]["base_minimum_remaining"]["core"] == 100
+ assert lower_priority["github_budget"]["priority_one_headroom"]["core"] == 600
+ assert lower_priority["github_budget"]["priority_headroom_applied"] is True
+ assert lower_priority["github_budget"]["admission_priority"] == 2
+ assert isolated_db.pipeline_github_budget_reservations(
+ "github-default")["count"] == 0
+
+
+def test_legacy_zero_headroom_keeps_admission_unchanged_without_priority():
+ profile = _profile_with_costs(core=500)
+ base = pipeline_insights._budget_policy_for_route(
+ pipeline_insights._github_budget_policy(profile), "skill")
+
+ selected = pipeline_insights._budget_policy_for_admission_priority(
+ base, None)
+ decision = pipeline_insights._evaluate_github_budget(
+ selected, _limits(core=600), now=1000)
+
+ assert selected["minimum_remaining"] == base["minimum_remaining"]
+ assert decision["allowed"] is True
+ assert decision["priority_one_headroom"] == {
+ "core": 0, "search": 0, "graphql": 0,
+ }
+ assert decision["priority_headroom_applied"] is False
+ assert decision["admission_priority"] is None
+
+
def test_opt_in_default_core_floor_covers_onebase_full_workflow():
profile = _profile()
profile["github_budget"]["minimum_remaining"] = {}
@@ -293,6 +448,35 @@ def forbidden(*_args, **_kwargs):
assert cached["github_rate_limit"]["core"]["remaining"] == 1
+def test_insights_refresh_preserves_priority_one_headroom(
+ isolated_db, monkeypatch):
+ profile = _profile_with_costs(core=500)
+ profile["github_budget"]["priority_one_headroom"] = {
+ "core": 600, "search": 0, "graphql": 0,
+ }
+ monkeypatch.setattr(
+ pipeline_insights, "_profiles", lambda: {"example": profile})
+ monkeypatch.setattr(
+ pipeline_insights, "_github_rate_limits",
+ lambda: _limits(core=1100))
+
+ def forbidden(*_args, **_kwargs):
+ raise AssertionError("headroom-protected insights scan started")
+
+ monkeypatch.setattr(pipeline_insights, "_run_profile_health_check", forbidden)
+ monkeypatch.setattr(pipeline_insights, "_github_search", forbidden)
+ pipeline_insights._cache.clear()
+
+ result = pipeline_insights.analyze("example", [], use_cache=False)
+
+ assert result["cache"]["refresh_blocked"] == "low"
+ assert result["github_budget"]["minimum_remaining"]["core"] == 700
+ assert result["github_budget"]["base_minimum_remaining"]["core"] == 100
+ assert result["github_budget"]["priority_headroom_applied"] is True
+ assert result["github_budget"]["admission_priority"] == 10
+ assert isolated_db.list_pipeline_snapshots("example") == []
+
+
def test_unavailable_rate_limit_fails_closed_without_scan(
isolated_db, monkeypatch):
profile = _profile()
@@ -723,6 +907,14 @@ def test_web_dashboard_labels_projection_as_non_actual_github_remaining():
assert "const ledgerKnown = githubBudgetState.ledger_state !== 'unavailable'" \
in html
assert "ledgerKnown ? budgetNumber(reservedBudget, 'core') : '—'" in html
+ assert "const priorityHeadroom = githubBudgetState.priority_one_headroom || {}" \
+ in html
+ assert "const hasPriorityHeadroom = ['core', 'search', 'graphql']" in html
+ assert "Резерв для приоритета PromptPilot 1: REST " \
+ "${budgetNumber(priorityHeadroom, 'core')}" in html
+ assert "задачи приоритетов 2–10 и полное обновление статистики " \
+ "оставляют этот запас" in html
+ assert "${priorityHeadroomNote}" in html
def test_web_task_detail_labels_only_pending_github_wait_as_wait_reason():