Skip to content

fix(cluster): async drain_worker/cancel_drain + _lease_lock on force-release + wire arbiter task cancellation#1718

Open
hognek wants to merge 17 commits into
jaylfc:devfrom
hognek:feat/worker-auto-update-rebased
Open

fix(cluster): async drain_worker/cancel_drain + _lease_lock on force-release + wire arbiter task cancellation#1718
hognek wants to merge 17 commits into
jaylfc:devfrom
hognek:feat/worker-auto-update-rebased

Conversation

@hognek

@hognek hognek commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes #1690 (lock fix) and cross-cutting arbiter wiring for the taOS arbiter epic (#1683/#1705/#1706/#1707/#1690).

Changes

#1690 lock fixdrain_worker force-release and _monitor_loop stale-drain path now hold _lease_lock during self._leases mutation, serialized with claim/release/sweep. drain_worker and cancel_drain are now async def (consistent with claim_lease/release_lease — all callers were already in async handlers).

Cross-cutting wiring — Added GpuArbiter.cancel_running_for_leases() and wired it into:

  • drain_worker(graceful=False) — after force-releasing leases
  • _monitor_loop stale-drain branch — when a draining worker stops heartbeating

When leases are force-released, any running GPU arbiter tasks for those leases are cancelled via _evict_task. Eviction is no longer a prod no-op when the arbiter is running.

Also: Added TYPE_CHECKING import for GpuArbiter type annotation on ClusterManager._gpu_arbiter.

Files changed

  • tinyagentos/cluster/manager.py — async drain_worker/cancel_drain, lock fix, arbiter cancellation wiring
  • tinyagentos/routes/cluster.py — await on async drain_worker/cancel_drain calls
  • tinyagentos/scheduler/gpu_arbiter.py — new cancel_running_for_leases() method
  • tests/test_cluster.py — await on all 10 drain_worker/cancel_drain test calls

Tests

47/47 pass: tests/test_cluster.py (26), tests/test_gpu_arbiter_894.py (14), tests/test_gpu_arbiter_toctou.py (7)

hognek added 16 commits July 7, 2026 12:37
…ction

Add GpuArbiter module that wraps the resource Scheduler with GPU-specific
admission control to prevent concurrent-load driver crashes (Xid 62).

Key features:
- VRAM-aware admission: checks local VRAM probes and cluster worker
  leases before admitting GPU tasks
- Priority queue: pending GPU tasks wait when VRAM is insufficient,
  dequeued in priority order when resources free up
- Eviction: lower-priority running tasks can be evicted to make room
  for higher-priority work
- Lease integration: claims/releases GPU leases via ClusterManager
  for distributed coordination
- GPU resource registration: discovery.py now registers gpu-cuda-N
  resources when GPU backends are healthy
- GPU_POTENTIAL_CAPABILITIES constant for UI latent-capability display

19 unit tests pass. Builds on Slice 1 (VRAM endpoint, jaylfc#893 leases).

Task: t_d7208884. Fixes jaylfc#894.
… futures

_evict_task previously only cancelled the _arbiter_future on the Task
object. Directly-admitted tasks never have one set, and for queued-then-
admitted tasks the future is resolved by _drain_queue before _run_gpu_task
completes. In both cases the running GPU work keeps executing and VRAM is
never freed until natural completion.

Now _run_gpu_task registers its asyncio.Task in _running_tasks, and
_evict_task cancels that Task directly. CancelledError propagates to
the payload, the finally block cleans up VRAM/leases, and eviction
actually stops running GPU work.

- Added _running_tasks dict to track asyncio.Tasks
- _run_gpu_task registers current task and handles CancelledError
- _evict_task cancels the asyncio.Task (in addition to _arbiter_future)
- _drain_queue catches CancelledError from evicted tasks to keep
  queue processor alive
- Finally block checks if task was already evicted (entry popped)
  before releasing lease — prevents double-release

Fixes jaylfc#894.
_evict_task now uses pop(task_id, None) atomically instead of the
check-then-pop pattern, eliminating the TOCTOU KeyError that could
occur when _run_gpu_task's finally block pops the entry between
the existence check and the pop.

Ownership is now explicit: whoever pops self._running first releases
the lease.  _evict_task's pop is the sole lease releaser for evicted
tasks; _run_gpu_task's finally only releases on normal completion
(when it still finds the entry).  Comments document the invariant.

Refs jaylfc#894.  Task: t_0c86d08e.
…ncompatible with stack

The file was incorrectly brought in from the jaylfc#1689 version and references
VramAllocation, total_vram_mb, headroom_mb, and evict_callback that don't
exist in the stack's GpuArbiter.  test_gpu_arbiter_894.py covers the same
territory with the correct API.
…M reservation

_addmission_check read live nvidia-smi free VRAM with no reservation,
so two concurrent submit_gpu calls could both pass before either model
loaded — the exact concurrent-load crash (NVIDIA Xid 62) the arbiter
exists to prevent.

Add _reserved_vram_mb counter and _pending_reservations dict, guarded
by _reservation_lock.  _reserve_and_check() atomically checks admission
against (probe - _reserved_vram_mb) and reserves on success.  Reservations
are released in _run_gpu_task's finally block and in _evict_task.

_stats() now includes reserved_vram_mb and pending_reservations for
observability.

Fixes: PR jaylfc#1683 review feedback #2
Tests: 168 pass (25 arbiter + 143 cluster/worker)
…ed VRAM in cluster path

taOS jaylfc#1705 — reservation leak fix:
- Move lease claim and _running registration inside try block so the
  finally always releases the VRAM reservation, even on claim failure.
- Await claim_lease / release_lease (both are async on ClusterManager).
- Make _evict_task and evict_lowest_priority async (they await release_lease).
- Subtract _reserved_vram_mb in the cluster-mode admission path, matching
  the local-path behaviour so in-flight reservations are accounted for.
- Update tests: FakeClusterManager methods made async, all _evict_task and
  evict_lowest_priority calls now awaited.

Tests: 16/16 arbiter + 111/111 cluster pass.
…iter

_drain_queue no longer blocks on _run_gpu_task.  The admitted task is
spawned as a background asyncio Task with a done-callback that propagates
the result/exception to the submitter's _arbiter_future.  This keeps the
drain loop responsive on subsequent ticks so eviction-to-make-room can
kick in when higher-priority tasks arrive while VRAM is full.

Also adds eviction-to-make-room: when admission fails for a queued task
the drain now calls evict_lowest_priority with the task's priority as
the floor, then re-checks admission.  Lower-priority running tasks are
evicted to make room; higher-priority runners are left alone.

Queue processing remains intentionally serial — only one task is admitted
per drain cycle to avoid flooding the GPU with concurrent loads.

PR jaylfc#1683 review feedback jaylfc#4.
taOS jaylfc#1706 — TOCTOU fix:
- After eviction-to-make-room frees VRAM, the re-admit in _drain_queue
  used _check_admission (no reservation) instead of _reserve_and_check
  (atomic reservation).  This spawned tasks with no reservation entry,
  re-opening the TOCTOU window and causing _reserved_vram_mb drift.
- Changed to _reserve_and_check so post-eviction admission is atomic
  and properly tracked.
- Also fixes the missing await on evict_lowest_priority (made async
  by the base jaylfc#1705 fix) and sync _evict_task calls in new tests.

Tests: 22/22 arbiter pass.
…biter

Make evict_lowest_priority, _evict_task, release_tasks_for_worker,
stats, and running_tasks async and guard _running dict access with
_running_lock. Previously _running was mutated under the lock in
_run_gpu_task but read/popped without it in eviction paths — safe
under single-thread-no-await but fragile. Lease release and task
cancellation happen outside the lock to avoid deadlock.

Update all callers: drain_worker → async, route handlers, and tests.
69 targeted tests pass.
- Added vram_probe=_probe_nvidia_vram so the arbiter uses actual GPU
  VRAM instead of the default (0,0) probe that makes all checks pass.
- Wired cluster_manager._gpu_arbiter so eviction paths that access
  the cluster manager can reach the arbiter.
- Added gpu_arbiter.stop() call in shutdown sequence.
- Added _gpu_arbiter attribute to ClusterManager.__init__.
taOS jaylfc#1707 — _running_lock coverage fix:
- _evict_task now holds _running_lock during the pop, preventing races
  with concurrent drain operations.
- stats() made async (needed for _running_lock consistency).
- All async call sites now properly awaited (evict_lowest_priority,
  _evict_task, stats, claim_lease, release_lease).

Tests: 22/22 arbiter pass.
…, restart

Adds drain_worker/cancel_drain to ClusterManager for graceful worker
detach without dropping inflight GPU tasks (taOS jaylfc#890).

ClusterManager:
- drain_worker(name, graceful=True): worker enters 'draining' status,
  excluded from routing/catalog/lease claims. When graceful=False,
  all leases are force-released and worker is marked offline.
- cancel_drain(name): returns draining worker to 'online'.
- _monitor_loop: auto-completes drain when all leases released;
  force-finishes stale drains on heartbeat timeout.
- _worker_for_resource, get_workers_for_capability, aggregate_catalog:
  all exclude draining workers.

Routes:
- POST /api/cluster/workers/{name}/drain — begin graceful/force drain
- POST /api/cluster/workers/{name}/cancel-drain — cancel in-progress drain
- POST /api/cluster/workers/{name}/update — full auto-update orchestration:
  drain → deploy update-worker → restart → re-register

Tests: 9 new tests (25 total in test_cluster.py), 191 cluster tests pass.
Replace startswith(name + ':') with _parse_resource_id exact match
to prevent draining worker 'foo' from releasing leases belonging to
worker 'foo-bar' (same prefix-collision class as TOCTOU fixes).
taOS jaylfc#796 features extracted from jaylfc#1689 and rebased onto the arbiter stack:

1. pause/resume queue control: GpuArbiter.pause()/resume() halt/restart
   queue processing. Running tasks finish; queued tasks wait. Paused
   queue skips _drain_queue in _process_queue loop.

2. Hardware-aware LLM admission: submit_gpu() accepts required_gpu_arch
   (e.g. 'sm_86'). _check_gpu_arch_compatibility() checks cluster workers
   for compatible GPU hardware via model name and compute_cap field
   before admitting tasks.
…r_894

_evict_task was made async in commit 1789ad0 (running-lock fix).
Three cleanup calls were missing await.
…on force-release, wire arbiter task cancellation

jaylfc#1690 lock fix: drain_worker force-release and _monitor_loop stale-drain
path now hold _lease_lock during self._leases mutation, serialized with
claim/release/sweep. drain_worker and cancel_drain are now async
(consistent with claim_lease/release_lease — all callers were already
in async handlers).

Cross-cutting wiring: added GpuArbiter.cancel_running_for_leases() and
wired it into drain_worker(graceful=False) and _monitor_loop stale-drain
path. When leases are force-released, any running GPU arbiter tasks
for those leases are cancelled via _evict_task — eviction is no longer
a prod no-op when the arbiter is running.

Added TYPE_CHECKING import for GpuArbiter type annotation on
ClusterManager._gpu_arbiter.
@coderabbitai

coderabbitai Bot commented Jul 7, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@hognek, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 25 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: ec2dca29-dcdc-4fdc-a276-3dd8325606b8

📥 Commits

Reviewing files that changed from the base of the PR and between a54f72d and b6e17e6.

📒 Files selected for processing (11)
  • .gitignore
  • tests/test_cluster.py
  • tests/test_gpu_arbiter_894.py
  • tests/test_gpu_arbiter_toctou.py
  • tinyagentos/app.py
  • tinyagentos/cluster/manager.py
  • tinyagentos/routes/cluster.py
  • tinyagentos/scheduler/__init__.py
  • tinyagentos/scheduler/discovery.py
  • tinyagentos/scheduler/gpu_arbiter.py
  • tinyagentos/scheduler/types.py
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@hognek hognek marked this pull request as ready for review July 7, 2026 20:46
@qodo-code-review

Copy link
Copy Markdown

Qodo reviews are paused for this user.

Troubleshooting steps vary by plan Learn more →

On a Teams plan?
Reviews resume once this user has a paid seat and their Git account is linked in Qodo.
Link Git account →

Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center?
These require an Enterprise plan - Contact us
Contact us →

Comment thread tinyagentos/cluster/manager.py Outdated
self._leases.pop(lid, None)
released += 1
logger.info("Worker '%s' drain: force-released %d leases", name, released)
worker.status = "offline"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: worker.status = "offline" is set after the async with self._lease_lock: block exits. The lease pops are serialized under the lock, but the status transition is not, so a concurrent claim_lease (or any reader that filters on worker.status) can observe the worker as still "draining" while the leases are already gone, producing a transient state where _worker_for_resource rejects new claims for a worker that no longer owns any leases. Move the status flip inside the lock block (or set it before acquiring the lock) so the lease-table mutation and the status transition are atomic with respect to readers.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

Comment thread tinyagentos/cluster/manager.py Outdated
]
for lid in lids:
self._leases.pop(lid, None)
worker.status = "offline"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CRITICAL: Race in stale-drain path — worker.status = "offline" is set after releasing self._lease_lock. Because _monitor_loop snapshots workers via list(self._workers.values()) and the lock is only held for the lease-table mutation, the next 5 s tick (or any concurrent iteration that re-reads worker.status) can see the worker still in "draining" with zero active leases and re-enter the if not active_leases: branch above (line 641), emitting a duplicate worker.leave notification and a second "all leases released" log line. Set worker.status = "offline" while still holding self._lease_lock (or before the lock block), so the lease-pop and the status flip are atomic with respect to the monitor's next read.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

Comment thread tinyagentos/cluster/manager.py Outdated
# released leases (taOS cross-cutting wiring).
if lids and self._gpu_arbiter is not None:
try:
await self._gpu_arbiter.cancel_running_for_leases(set(lids))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: cancel_running_for_leases(set(lids)) is invoked after _lease_lock is released and after worker.status = "offline". The list lids was captured under the lock so it's accurate, but the await here runs without any lock held — combined with the status being already "offline", a concurrent register_worker for the same name could race against the cancellation. Worth holding a brief status guard, or at minimum logging when set(lids) is empty after lock release (no leases to cancel) to avoid a noisy "cancelled 0 tasks" trace. Also note: this call is reached only when the worker is stale (heartbeat timeout); the in-flight _run_gpu_task may have already crashed — the except Exception correctly swallows but the operator gets no signal that a running LLM was force-killed.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

"""
if not lease_ids:
return 0
victim_ids: list[str] = []

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: cancel_running_for_leases reads self._running under self._running_lock, drops the lock, then awaits _evict_task per victim. Between releasing the lock and the _evict_task await, a concurrent natural completion can remove the entry from _running, so _evict_task(task_id) silently returns 0 and the cancellation is dropped (benign for completed tasks, but the caller can't tell). Consider returning distinct counts (e.g. {cancelled, already_done, missing}) or just logging len(victim_ids) - cancelled as already_completed so operators can see force-kill vs natural-completion. Also: this method iterates self._running.items() which is a dict[str, tuple] — fine, but the unpacking _task, lease_id, _pri, _vram discards lease_id without using it after the membership test; you could simplify with if any(lease_id in lease_ids for ...) if you only need the task_ids.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

@kilo-code-bot

kilo-code-bot Bot commented Jul 7, 2026

Copy link
Copy Markdown

Code Review Summary

Status: 1 Issue Found | Recommendation: Approve with minor suggestion

Overview

Severity Count
CRITICAL 0
WARNING 0
SUGGESTION 1
Issue Details (click to expand)

SUGGESTION

File Line Issue
tinyagentos/scheduler/gpu_arbiter.py 439 already_completed mislabels "task missing from _running for any reason" — could include prior eviction or error-path cleanup, not just natural completion
Previous Issues — Resolved by b6e17e6
  • manager.py:666 CRITICAL (stale-drain race) — fixed: worker.status = "offline" moved inside _lease_lock block (line 669)
  • manager.py:447 WARNING (drain_worker status flip outside lock) — fixed: status flip now at line 446, inside the lock
  • manager.py:676 WARNING (silent cancellation result) — fixed: tuple return (cancelled, already_done) now logged at lines 454-456 and 680-682
  • gpu_arbiter.py:428 SUGGESTION (distinct cancellation counts) — fixed: returns tuple[int, int] with (cancelled, already_completed)
Files Reviewed (2 files)
  • tinyagentos/cluster/manager.py — 0 new issues (both lock-correctness fixes verified)
  • tinyagentos/scheduler/gpu_arbiter.py — 1 issue (semantic naming on already_completed)

Fix these issues in Kilo Cloud

Previous Review Summary (commit 814e5fd)

Current summary above is authoritative. Previous snapshots are kept for context only.

Previous review (commit 814e5fd)

Status: 4 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 1
WARNING 2
SUGGESTION 1
Issue Details (click to expand)

CRITICAL

File Line Issue
tinyagentos/cluster/manager.py 666 Race in stale-drain path — worker.status = "offline" is set after releasing _lease_lock, allowing duplicate worker.leave notifications on the next monitor tick

WARNING

File Line Issue
tinyagentos/cluster/manager.py 447 worker.status = "offline" set outside _lease_lock in drain_worker(graceful=False) — lease-pop and status flip not atomic
tinyagentos/cluster/manager.py 676 cancel_running_for_leases invoked after lock release and after status flip; cancellation result is silent if running tasks already completed

SUGGESTION

File Line Issue
tinyagentos/scheduler/gpu_arbiter.py 427 Return distinct cancellation counts (cancelled vs already_completed) so operators can distinguish force-kills from natural completions
Files Reviewed (4 files)
  • tinyagentos/cluster/manager.py — 3 issues
  • tinyagentos/scheduler/gpu_arbiter.py — 1 issue
  • tinyagentos/routes/cluster.py — 0 issues (changes are mechanical: await additions + _require_admin guards)
  • tests/test_cluster.py — 0 issues (mechanical await additions on test calls)

Fix these issues in Kilo Cloud


Reviewed by minimax-m3 · Input: 41.5K · Output: 6.5K · Cached: 738.8K

@gitar-bot

gitar-bot Bot commented Jul 7, 2026

Copy link
Copy Markdown

Gitar is working

Gitar

… (cancelled, already_completed) from cancel_running_for_leases

Addresses Kilo review findings on PR jaylfc#1718:

CRITICAL: Race in stale-drain path — worker.status='offline' was set
after releasing _lease_lock, allowing duplicate worker.leave on next
monitor tick. Now set inside the lock (both drain_worker force-release
and _monitor_loop stale-drain paths).

WARNING: Same pattern in drain_worker(graceful=False) — fixed.

SUGGESTION: cancel_running_for_leases now returns
(cancelled, already_completed) so operators can distinguish force-kills
from natural completions. Callers log both counts.
@hognek

hognek commented Jul 7, 2026

Copy link
Copy Markdown
Contributor Author

Addressed all 4 Kilo review findings in b6e17e6:

  • CRITICAL (manager.py:666): Moved worker.status = "offline" inside _lease_lock in stale-drain path — prevents duplicate worker.leave on next monitor tick.
  • WARNING (manager.py:447): Same fix applied to drain_worker(graceful=False) path.
  • WARNING (manager.py:676) + SUGGESTION (gpu_arbiter.py:427): cancel_running_for_leases now returns (cancelled, already_completed) — operators can distinguish force-kills from natural completions. Both callers log both counts.

47/47 tests pass.

if await self._evict_task(task_id):
cancelled += 1
else:
already_completed += 1

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: already_completed is misleading — _evict_task returns 0 whenever task_id not in self._running, which includes not only natural completion but also prior eviction by evict_lowest_priority, error-path cleanup, or a concurrent admin action. Renaming to already_gone or splitting into {cancelled, completed, missing} would let operators distinguish a normal completion from an unexpected removal. Worth considering before this signal becomes load-bearing for alerting.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

@jaylfc

jaylfc commented Jul 8, 2026

Copy link
Copy Markdown
Owner

Did a full SET review of the arbiter epic across every branch (#1683/1690/1705/1706/1707/1718/1725/1726/1727). #1718 is the one to converge on: it is the rebased superset composing #1683 + #1705 + #1706 + #1707 + #1690 plus pause/resume and the blocker-2/4 fixes, and kilo is clean on it (0 critical, 0 warning). My four original composed-state blockers are all confirmed fixed in #1718:

  • evict_lowest_priority is now awaited (gpu_arbiter.py:445 async def; call site :515 await).
  • post-eviction re-check uses await _reserve_and_check (:517), not _check_admission - TOCTOU closed.
  • lease claim is inside _run_gpu_task's try/finally (:374/:400), and the cluster admission subtracts _reserved_vram_mb (:347) - leak closed.
  • /drain, /cancel-drain, /update have _require_admin (:1124/1137/1158); _leases mutations are under _lease_lock.

Thank you - the mechanical blockers landed. But the epic is not mergeable as a set yet. Blocking:

  1. THE EPIC GATE IS STILL OPEN (the Fix store catalog type taxonomy — live API categories broken #185 gate). GpuArbiter.submit_gpu has ZERO production callers in any branch - no route, scheduler, or model-load path admits work through the arbiter, so _running is always empty in prod and eviction + cancel_running_for_leases are no-ops. The arbiter ships inert. And feat(models): atomic VRAM check-and-reserve before model load (TOCTOU fix, #1706) #1725 adds REAL wired VRAM admission via a parallel VramReservationManager (app.state.vram_reservation, used in routes/models.py) that bypasses the arbiter entirely. As composed the epic ships TWO unintegrated VRAM systems - an inert arbiter and a live model-load reservation that does not know the arbiter exists. We need ONE authority: route a real admission through GpuArbiter.submit_gpu and reconcile feat(models): atomic VRAM check-and-reserve before model load (TOCTOU fix, #1706) #1725 into it, or consciously drop the arbiter and keep only feat(models): atomic VRAM check-and-reserve before model load (TOCTOU fix, #1706) #1725 - but we cannot ship "the arbiter epic" with the arbiter dormant.

  2. LATENT EVICTION TOCTOU in fix(cluster): async drain_worker/cancel_drain + _lease_lock on force-release + wire arbiter task cancellation #1718 (fix before submit_gpu is ever wired). _evict_task frees the reservation (:467) BEFORE cancelling the evicted task (:471), and cancel() only schedules cooperative cancellation. So evict-to-make-room (_drain_queue:515-517) can free A's 4GB, pass B's _reserve_and_check against the now-free 4GB, and start loading B while A's model is still physically resident -> concurrent load -> VRAM over-subscription = the Xid-62 crash the arbiter exists to prevent. Masked today only because the arbiter is dormant, so it activates the moment submit_gpu is wired. Fix: free the reservation only after cancellation completes and physical VRAM is reclaimed (await the cancelled task, or probe before re-admitting).

  3. feat(models): atomic VRAM check-and-reserve before model load (TOCTOU fix, #1706) #1725 unresolved kilo CRITICAL: routes/models.py:377 reserves min_ram_mb (host RAM) as VRAM -> over-reserves -> false 503 denials; use a VRAM estimate. Plus warnings: :430 reservation leaks if start_installer_task raises before the bg task runs; vram_reservation.py:90 stats()/available_vram() probe outside the lock.

  4. fix(cluster): release GPU leases on worker unregister (fixes #1705) #1726 unresolved kilo WARNING: manager.py:274 self._workers.pop(name) runs AFTER the _lease_lock block exits, so any future await between release and pop reopens the exact leak it fixes. Move the pop inside async with self._lease_lock.

  5. feat(scheduler): GPU arbiter with drain→arbiter wiring (taOS #1707) #1727 - please close it. It is a competing single-commit rewrite of gpu_arbiter.py + drain_worker that conflicts with fix(cluster): async drain_worker/cancel_drain + _lease_lock on force-release + wire arbiter task cancellation #1718 (both create the same file) and is strictly worse: drops the app.py wiring (set_gpu_arbiter never called), omits the feat(cluster): worker auto-update with graceful drain, pause, install, restart (taOS #890) #1690 worker-update routes, and carries CRITICAL :421 (worker.free_vram_mb is None for non-NVIDIA/unreported workers -> TypeError on every admission), :585 (no general except -> one bad drain kills the queue processor), and :190 (stop() never resolves pending _arbiter_futures -> hung submit_gpu callers).

  6. Minor on fix(cluster): async drain_worker/cancel_drain + _lease_lock on force-release + wire arbiter task cancellation #1718: routes/cluster.py:1185 resp.json() lacks raise_for_status, so a non-2xx worker response with a JSON error body reports status:"updating" (silent success).

MERGE PLAN (I will land it as a coherent package, not piecemeal):

Once 1-6 are green together I will merge #1718 + #1726 + #1725 as the package. Solid work clearing the mechanical blockers; the rest is integration + the eviction-ordering safety.

@hognek

hognek commented Jul 8, 2026

Copy link
Copy Markdown
Contributor Author

Agreed on converging everything on #1718 — the composed-state analysis is exactly right, thanks for the thorough SET pass.

On item 1 (the #185 gate): let's do it properly. Wire a real admission path through GpuArbiter.submit_gpu and make the arbiter the single VRAM authority, rather than shipping it dormant beside #1725's parallel reservation. Concretely: the model-load path in routes/models.py admits through submit_gpu, and #1725's VramReservationManager becomes the arbiter's internal accounting rather than a second gate. That closes #185 for real and gives us one authority instead of two.

One shape question before I build the reconciliation, so I match your intended API the first time: should the arbiter be the front door with VramReservationManager demoted to its reservation bookkeeping, or keep VramReservationManager as the public reserve API with submit_gpu calling through it? Either works — I'll follow your preference for the long-term surface.

I'll land item 2's eviction-ordering fix in the same change, since wiring submit_gpu is exactly what arms that TOCTOU: free the reservation only after the evicted task's cancellation completes and its VRAM is physically reclaimed, not at _evict_task:467 ahead of cancel() at :471. And I'll fold the routes/cluster.py:1185 raise_for_status nit (item 6) into #1718.

Items 3 (#1725 min_ram_mb→VRAM estimate + the :430 leak + vram_reservation.py:90 probe-under-lock**)** and 4 (#1726 pop inside _lease_lock) I'll handle on their own PRs. Closing #1727 now as superseded.

Target sequence, landing as the one package you described: #1718 (eviction-ordering + resp.json + submit_gpu wiring) → #1726#1725 reconciled into the arbiter. Say the word if you'd rather I split the submit_gpu wiring into a follow-up PR on top of the mechanical #1718 fixes.

@jaylfc

jaylfc commented Jul 8, 2026

Copy link
Copy Markdown
Owner

Answering the shape question so you can build the reconciliation once: make the arbiter the front door. GpuArbiter.submit_gpu is the single public admission API, and VramReservationManager becomes the arbiter's internal reservation bookkeeping, not a second public gate.

The whole point of #185 is one VRAM authority. If VramReservationManager.reserve stays a public entry point, callers can still take VRAM without passing through admission (queue plus eviction plus accounting), which is exactly the parallel-path bypass the SET review flagged on #1725. So the model-load path in routes/models.py admits through submit_gpu, and everything that consumes VRAM is subject to the same gate.

On sequencing: your target is good and I will take it as the package. Keeping item 2's eviction-ordering fix in the same change as the submit_gpu wiring is correct for the reason you gave, the wiring is what makes the eviction path reachable, so they have to be consistent in one diff. No need to split it out.

The one hard constraint from #185: nothing merges piecemeal. #1718 (mechanical fixes plus eviction-ordering plus resp.json plus submit_gpu wiring) plus #1726 (pop inside _lease_lock) plus #1725 (min_ram_mb to real VRAM estimate, the :430 leak, the probe-under-lock, reconciled into the arbiter's internal accounting) land together as one consistent package after I re-review the composed state end to end. Ping me when the three are in their final shape and I will do the SET re-review before anything merges. Thanks for closing #1727 and for driving this cleanly.

@jaylfc

jaylfc commented Jul 9, 2026

Copy link
Copy Markdown
Owner

Hi @hognek — I did a full cross-PR pass over the arbiter epic (#1705/#1706/#1707/#1718/#1759 + #1690/#1726) to untangle it before merging, since they all rewrite scheduler/gpu_arbiter.py + app.py. Summary and a concrete plan:

Supersession (verified by commit ancestry, not titles): #1705#1706#1707#1718 is a strict git-ancestor chain — the first three are older snapshots of this same branch, so I am closing them as superseded (pointing here). #1759 branches off separately into a competing design (a second class also named VramReservationManager inside the arbiter) and Kilo flags it CRITICAL: it constructs GpuArbiter(...) with no vram_probe (admission is a fail-open no-op) and cluster_manager._gpu_arbiter is never read (eviction is a prod no-op). I am closing #1759 too — this PR (#1718) already does both correctly.

The keepers: #1690 (worker auto-update) merges clean and is the right home for that feature; #1726 (release leases on unregister) is a real fix (one trivial test conflict). Both land on their own.

The one architectural blocker on this PR: dev already merged the epic's first reservation layer — tinyagentos/vram_reservation.py::VramReservationManager (#1725, hotfixed by #1767), instantiated in app.py as app.state.vram_reservation. #1718 introduces a parallel reservation system (_reserved_vram_mb + _pending_reservations) that does not share state with it, so we would ship two VRAM authorities that double-count. Could you rework this PR to build on the merged VramReservationManager instead, and split the worker-auto-update commits back out to #1690 so this is arbiter-only?

Correctness items to fold in the rework (all in scheduler/gpu_arbiter.py unless noted):

  1. HIGH — fail-OPEN on probe failure (_check_admission): _probe_nvidia_vram collapses every error (nvidia-smi missing/timeout/parse) to (0,0), which admission reads as "no GPU → admit". A GPU box whose probe transiently fails then admits unlimited concurrent loads — the exact Xid-62 crash the arbiter prevents. Must fail closed when a GPU is expected (the merged manager already distinguishes "no probe" from "known-empty").
  2. HIGH — blocking nvidia-smi on the event loop, under _reservation_lock: two subprocess.run(timeout=5) calls (~10s) block the whole asyncio loop while holding the lock. The merged manager fixed this with await asyncio.to_thread(...); please reuse that path.
  3. MEDIUM — eviction preempts EQUAL priority (evict_lowest_priority): guard is pri < min_priority; with "lower int wins" that evicts a running same-priority task for a queued one (thrash). Should be pri <= min_priority: continue (only evict strictly-lower).
  4. MEDIUM — VRAM released before physical unload (_evict_task): accounting is freed, then the task is cancel()ed (cooperative), but the model stays resident until it actually unloads — a new admission sees phantom capacity. Tie the release to real unload (again what the merged manager is built to track).

Full write-up is long; happy to drop the rest (D-6/7 queued-caller-on-stop, D-8 double-count, D-9 worker.status write outside _lease_lock in the drain paths) inline if useful. No blocking rush — once this is arbiter-only + on the merged reservation manager + 1–4 are in, I will merge. Really solid work overall; the eviction/drain design is right, it just needs to converge on the one reservation authority.

@jaylfc

jaylfc commented Jul 11, 2026

Copy link
Copy Markdown
Owner

Hi @hognek, consolidating what is left to close the arbiter set as one package, since #1726 rebased cleanly and #1690 now gates the destructive routes with _require_admin (that closes the earlier auth CRITICAL, thanks). Remaining before I do the SET re-review and merge #1718 + #1726 + #1725 together:

  1. Gate-closer (this is what actually closes Fix store catalog type taxonomy — live API categories broken #185). submit_gpu still needs a real production caller: route the model-load admission in routes/models.py through GpuArbiter.submit_gpu, and make VramReservationManager the arbiter internal bookkeeping rather than a second public entry point, so there is ONE VRAM authority. Right now the arbiter ships inert and feat(models): atomic VRAM check-and-reserve before model load (TOCTOU fix, #1706) #1725 is a parallel path.

  2. Eviction ordering (fix(cluster): async drain_worker/cancel_drain + _lease_lock on force-release + wire arbiter task cancellation #1718). Free the reservation only after the evicted task physically unloads, not before cancel(). As composed, evict-to-make-room can pass the next admission against not-yet-reclaimed VRAM and over-subscribe once submit_gpu is wired.

  3. feat(models): atomic VRAM check-and-reserve before model load (TOCTOU fix, #1706) #1725 items: fail CLOSED on probe failure (a transient nvidia-smi failure must not read as no GPU then admit), run nvidia-smi via asyncio.to_thread off the event loop, and do not preempt EQUAL priority in evict_lowest_priority (use pri <= min: continue).

  4. fix(cluster): release GPU leases on worker unregister (fixes #1705) #1726: move self._workers.pop(name) inside the async with self._lease_lock block so a future await between release and pop cannot reopen the leak.

  5. Please close the superseded ones so the set is unambiguous: fix(scheduler): close TOCTOU race in GPU admission with in-flight VRAM reservation #1705, fix(scheduler): non-blocking drain + eviction-to-make-room in GPU arbiter #1706, fix(scheduler): add _running_lock coverage to eviction paths in GpuArbiter #1707 (strict git-ancestors of fix(cluster): async drain_worker/cancel_drain + _lease_lock on force-release + wire arbiter task cancellation #1718) and feat(scheduler): VramReservationManager — atomic VRAM bookkeeping for GpuArbiter [#1683] #1759 (competing design).

Optional, not blocking: rate limiting on the /drain, /cancel-drain, /update routes (already admin-gated).

Ping me when #1718 + #1726 + #1725 are in final shape and I will re-review the composed state end to end. Nothing merges piecemeal. Solid work, the drain and eviction design is right, it just needs to converge on the one reservation authority.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants