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():