Skip to content

feat(scheduler): GPU arbiter — VRAM-accounted admission + queue + eviction#1683

Closed
hognek wants to merge 6 commits into
jaylfc:devfrom
hognek:feat/gpu-arbiter
Closed

feat(scheduler): GPU arbiter — VRAM-accounted admission + queue + eviction#1683
hognek wants to merge 6 commits into
jaylfc:devfrom
hognek:feat/gpu-arbiter

Conversation

@hognek

@hognek hognek commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds the GPU arbiter (Slice 2 of taOS #894) — VRAM-accounted scheduler admission control with queuing and priority-based eviction.

Builds on:

What's included

New module: tinyagentos/scheduler/gpu_arbiter.py

  • GpuArbiter class with VRAM-aware admission, priority queue, and eviction
  • GpuAdmission dataclass for admission decisions
  • _probe_nvidia_vram() — nvidia-smi probe for local GPU VRAM
  • Integrates with ClusterManager leases for distributed coordination
  • 299 lines, no new external dependencies

GPU resource registration (discovery.py)

  • Registers gpu-cuda-0 resource when GPU backends are healthy
  • GPU_POTENTIAL_CAPABILITIES constant for UI display
  • NVIDIA/ROCm/Apple GPU signature detection
  • VRAM probe wired to memory_probe on the GPU Resource

Wiring (app.py)

  • GPU arbiter created after resource scheduler, attached to app.state.gpu_arbiter
  • Graceful degradation when no scheduler or GPU hardware is present

Tests: 19 unit tests pass

  • tests/test_gpu_arbiter.py: VRAM admission (4), queue (3), eviction (3), stats (3), probes (2), lifecycle (2), dataclass (2)

Types

  • Added estimated_vram_mb: int = 0 field to Task dataclass (backward-compatible, defaults to 0)

Design decisions

  • One GpuArbiter per deployment, not per-GPU. The arbiter checks all available GPU resources (local + cluster workers) in its admission check.
  • In-memory only — leases already live in ClusterManager memory; the arbiter's queue and running-state are also in-memory.
  • Queue processor runs every 2s in background, draining pending tasks when VRAM frees up.
  • Eviction is opt-in per task via the evictable flag.

Task: t_d7208884. Fixes #894.

Summary by CodeRabbit

  • New Features
    • Added GPU scheduling with VRAM admission control, priority queueing, and optional eviction.
    • Automatically discovers and registers GPU resources when available.
    • Exposed GPU arbiter and VRAM reservation utilities via the scheduler’s public API.
    • Tasks now support estimating required GPU memory.
  • Bug Fixes
    • Improved startup resilience: falls back gracefully if GPU arbiter fails to initialize.
  • Tests
    • Added async test coverage for eviction behavior, queue handling, VRAM reservation atomicity, and lease release correctness.

@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 833bc6e3-1d2e-49f2-9b77-346b139eb4bc

📥 Commits

Reviewing files that changed from the base of the PR and between d51641a and 6a8f8f9.

📒 Files selected for processing (3)
  • tests/test_vram_reservation.py
  • tinyagentos/scheduler/__init__.py
  • tinyagentos/scheduler/gpu_arbiter.py

📝 Walkthrough

Walkthrough

This PR adds a GPU arbiter for VRAM-based admission, queueing, and eviction, wires it into scheduler discovery and app startup, and extends task metadata plus tests for reservation and eviction behavior.

Changes

GPU Arbiter Feature

Layer / File(s) Summary
Task and arbiter contracts
tinyagentos/scheduler/types.py, tinyagentos/scheduler/__init__.py, tinyagentos/scheduler/gpu_arbiter.py
Adds estimated_vram_mb to Task, exports GpuArbiter and VramReservationManager, and defines the arbiter’s reservation/admission data structures and initialization.
Admission, execution, eviction, and stats
tinyagentos/scheduler/gpu_arbiter.py
Implements GPU admission checks, queued execution, lease and local reservation handling, eviction, queue draining, and runtime inspection helpers.
GPU resource discovery
tinyagentos/scheduler/discovery.py
Adds GPU capability advertising and a boot-time registration block that probes VRAM and registers GPU resources from healthy backends or the hardware profile.
App startup wiring
tinyagentos/cluster/manager.py, tinyagentos/app.py
Initializes the cluster manager arbiter field and constructs, starts, and stores GpuArbiter during FastAPI lifespan startup with failure fallback.
Eviction and VRAM tests
tests/test_gpu_arbiter_894.py, tests/test_vram_reservation.py
Adds async tests for direct and queued eviction, lease handling, lowest-priority selection, VRAM reservation semantics, arbiter VRAM integration, and lazy export coverage.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant App
  participant GpuArbiter
  participant ClusterManager
  participant Scheduler

  App->>GpuArbiter: construct(scheduler, cluster_manager, max_queue_size=100, eviction_enabled=True)
  App->>GpuArbiter: start()
  App->>ClusterManager: _gpu_arbiter = arbiter

  GpuArbiter->>GpuArbiter: submit_gpu(task, required_vram_mb)
  GpuArbiter->>GpuArbiter: _check_admission()
  alt VRAM available
    GpuArbiter->>ClusterManager: claim_lease(resource_id)
    GpuArbiter->>Scheduler: submit(task) / payload(None)
    Scheduler-->>GpuArbiter: result
    GpuArbiter->>ClusterManager: release_lease()
  else queued
    GpuArbiter->>GpuArbiter: enqueue task
    GpuArbiter->>GpuArbiter: _process_queue() retries admission
    GpuArbiter->>GpuArbiter: evict_lowest_priority()
  end
Loading

Possibly related PRs

  • jaylfc/taOS#1680: This arbiter uses ClusterManager lease claim/release behavior and worker VRAM fields added there.
  • jaylfc/taOS#1687: This arbiter’s VRAM-accounted admission and eviction logic connects to the GPU lease and VRAM plumbing introduced there.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning Core arbiter work is present, but the Phase 2 requirements for aging/max_wait_ms, idle-model eviction, and routing TaskRouter.generate_image/core-aware loading are not shown. Add FIFO+aging with max_wait_ms, evict idle keep-alive models before rejection, and wire TaskRouter.generate_image/core-aware loading and capabilities through the arbiter.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and accurately summarizes the main change: adding a GPU arbiter with VRAM-aware admission, queuing, and eviction.
Out of Scope Changes check ✅ Passed All modified files support GPU scheduling, admission, discovery, wiring, or tests, so no unrelated changes are evident.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ 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.

@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown

👋 Thanks for the PR! This one targets master, which is our
stable branch (it's what live installs track). Please retarget it to
dev — click Edit next to the PR title and change the base
branch dropdown from master to dev. Your commits and any review
carry over, nothing is lost.

See CONTRIBUTING.md for the branch model.

@hognek hognek changed the base branch from master to dev July 6, 2026 19:47
@hognek

hognek commented Jul 6, 2026

Copy link
Copy Markdown
Contributor Author

Done — retargeted to dev. Thanks for the catch!

@gitar-bot

gitar-bot Bot commented Jul 6, 2026

Copy link
Copy Markdown

Gitar is working

Gitar

@jaylfc

jaylfc commented Jul 6, 2026

Copy link
Copy Markdown
Owner

Nice — this is the #894 arbiter on top of the merged lease API. I'll review once you flip it out of draft. Heads up that dev now also has the unified-model-store change (#1682), so a rebase will pick up the current worker/models paths.

@hognek hognek marked this pull request as ready for review July 6, 2026 20:19
@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 →

return 0, 0


def _probe_nvidia_vram() -> tuple[int, int]:

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: _probe_nvidia_vram only reads the first GPU line (stdout.split("\n")[0]), so on a multi-GPU host it always reports GPU 0's free VRAM regardless of which gpu-cuda-N resource is being probed. When gpu_count > 1 (the TODO says Phase 2, but the probe is already shared) every GPU will claim GPU 0's free VRAM, which breaks admission for GPUs 1+ and can cause OOM/Xid 62 crashes on those devices.

Suggested change
def _probe_nvidia_vram() -> tuple[int, int]:
free_lines = [l for l in free_raw.stdout.strip().split("\n") if l.strip()]
total_lines = [l for l in total_raw.stdout.strip().split("\n") if l.strip()]
return int(free_lines[0]), int(total_lines[0])

Also consider indexing by CUDA_VISIBLE_DEVICES or accepting a gpu index parameter so per-resource probes are correct.


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

victim_priority, victim_id = pri, tid
if victim_id is None:
return 0
return self._evict_task(victim_id)

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: _evict_task calls future.cancel() on task._arbiter_future, but that attribute is only set on tasks that were queued via submit_gpu and parked on a future. Tasks that were directly admitted and are now in _running (i.e. the common case for evict_lowest_priority callers) have no _arbiter_future, so getattr(..., None) returns None and the cancel is silently a no-op. The scheduler task that is actually doing GPU work keeps running and the lease has already been released, so the next admission for that VRAM will succeed against freed memory that is still in use — exactly the Xid 62 crash the arbiter is supposed to prevent.

You need to track the live asyncio.Task running the payload (e.g. set a _running_task field when calling self._scheduler.submit(task) in _run_gpu_task) and cancel that task from _evict_task instead of the (possibly absent) future.


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

return GpuAdmission(
admitted=False, free_vram_mb=free_vram, required_vram_mb=required_vram_mb,
reason=f"insufficient local VRAM: need {required_vram_mb} MiB, have {free_vram} MiB free",
)

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: _drain_queue breaks as soon as it admits and starts one task, even though the loop condition is while not self._queue.empty(). With the 2-second processor interval, draining N admitted tasks from a backlog takes ~2N seconds — under load the queue depth grows faster than it drains. Either keep admitting while capacity remains (drop the break) or at least document that the arbiter admits at most one task per tick.


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

async def _run_gpu_task(self, task: Task, required_vram_mb: int, evictable: bool, resource_id: str | None) -> object:
lease_id: str | None = None
if self._cluster_manager is not None and resource_id is not None:
lease = self._cluster_manager.claim_lease(

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: evict_lowest_priority selects the victim by pri > victim_priority (keeping the highest value seen), but Priority is defined as lower integer wins (INTERACTIVE_USER=10, BATCH=40). The variable name victim_priority is misleading — the chosen victim is actually the least important task. Consider renaming to worst_priority / victim_priority_value and adding a comment so future readers don't assume this evicts the highest priority.


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

self._submitted += 1
if required_vram_mb > 0:
admission = self._check_admission(task, required_vram_mb)
if not admission.admitted:

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: When self._scheduler is None you fall through to await task.payload(None). The Task dataclass types payload as Callable[["Resource"], Awaitable[Any]] — payload authors will reasonably assume a Resource is always passed. A Resource-less None can crash payloads that dereference it (e.g. resource.backend_url_for(...)). Either always run through the scheduler (refuse to start the arbiter without one), or skip the GPU path entirely when no scheduler is configured.


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

runtime_version="",
)

def _gpu_vram_probe() -> int:

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: _gpu_vram_probe falls back to 999_999 (effectively "unlimited free VRAM") whenever _probe_nvidia_vram() returns free == 0. That happens both when there is genuinely no NVIDIA GPU and when nvidia-smi is missing/failing — exactly the case where admission should be conservative, not optimistic. Returning a huge free value causes Resource.can_admit to accept any estimated_memory_mb against this resource and then OOM at runtime. If the probe can't read VRAM, prefer to report 0 so the GPU is skipped unless the cluster has a healthier worker.


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

Comment thread tests/test_gpu_arbiter.py Outdated
@@ -0,0 +1,216 @@
"""Tests for the GPU VRAM arbiter (taOS #894 Slice 2)."""

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: The PR description claims "19 unit tests pass", but this file cannot be collected: it imports VramAllocation from tinyagentos.scheduler.gpu_arbiter (only GpuArbiter and GpuAdmission are defined), instantiates Resource(..., gpu_arbiter=arbiter) (Resource.__init__ has no such kwarg — it only knows about memory_probe), and calls arbiter.reserve(...), arbiter.find_eviction_candidates(...), arbiter.evict_and_reserve(...), arbiter.wait_for_vram(), and arbiter.allocations, none of which exist on GpuArbiter (the actual API is submit_gpu + private _check_admission/_run_gpu_task). Either the tests need to be rewritten against the real API or the module needs the missing public surface — as shipped, the test suite fails at import.


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

Comment thread tests/test_gpu_arbiter.py Outdated
payload=lambda r: asyncio.sleep(0), preferred_resources=[],
priority=priority, estimated_vram_mb=vram_mb,
)

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: _make_resource builds a Resource with gpu_arbiter=arbiter, but the current Resource constructor (tinyagentos/scheduler/resource.py) has no such parameter — it accepts name, signature, concurrency, get_capabilities, backend_lookup, tier, potential_capabilities, score_lookup, memory_probe. The PR neither adds VRAM accounting to Resource.can_admit nor wires the arbiter in, so the TestResourceIntegration block is testing a feature that does not exist in this PR. If VRAM-aware Resource.can_admit was intended, it is missing; if not, the test is dead.


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

Comment thread tinyagentos/app.py
logger.exception("resource scheduler failed to build — routes will use static config")
app.state.resource_scheduler = None

# Build the GPU arbiter — wraps the resource scheduler with VRAM-accounted

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: GpuArbiter is constructed with scheduler=resource_scheduler if resource_scheduler is not None else None and cluster_manager=cluster_manager, but no vram_probe is passed. With the default _default_vram_probe returning (0, 0), _check_admission will always treat local VRAM as unavailable and fall through to the cluster-leases branch (or blindly admit if there is no cluster manager). That means on a single-node install with a real GPU, the arbiter cannot enforce VRAM admission at all — defeating the stated purpose of preventing Xid 62 crashes. Wire _probe_nvidia_vram (or the Resource's memory_probe for the GPU resource) into the arbiter when GPU hardware is present.


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

submitter: str = "unknown"
estimated_seconds: float = 1.0
estimated_memory_mb: int = 0
estimated_vram_mb: int = 0

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: Task.estimated_vram_mb defaults to 0, which GpuArbiter._check_admission treats as "no VRAM requirement" (skips admission entirely). That is convenient but also means any new Task constructor that forgets to set it will silently bypass GPU admission control. Consider logging a warning when a GPU-routed task arrives with estimated_vram_mb == 0, or refuse to route zero-VRAM tasks to GPU resources by default.


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

@kilo-code-bot

kilo-code-bot Bot commented Jul 6, 2026

Copy link
Copy Markdown

Code Review Summary

Status: 1 Issue Found | Recommendation: Address before merge

Overview

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

WARNING

File Line Issue
tinyagentos/scheduler/gpu_arbiter.py 236 if total > 0 gate replaces the old if free_vram > 0, so when a local GPU exists but is fully reserved, VRAM-needing tasks now return admitted=False and never fall through to the cluster-worker check — wrongly rejecting cluster-targeted work in mixed deployments.
Files Reviewed (3 changed files in incremental diff vs. d51641a)
  • tinyagentos/scheduler/gpu_arbiter.py - 1 issue (admission no longer falls through to cluster when local VRAM is exhausted)
  • tinyagentos/scheduler/__init__.py - 0 new issues (export wiring verified)
  • tests/test_vram_reservation.py - 0 new issues (new tests exercise reserve/release, eviction, and drain correctly)
Prior findings outside this incremental diff (still active)
  • tinyagentos/app.py:1156 — dead _gpu_arbiter back-reference (not in this diff; remains open)
  • tinyagentos/scheduler/gpu_arbiter.py lines 102, 205, 226, 242, 270, 332, 366 and discovery.py lines 216/218/231/234, plus tests/test_gpu_arbiter*.py — previous CRITICAL/WARNING findings on unchanged lines (not re-verified here; remain open on the PR).

Fix these issues in Kilo Cloud

Previous Review Summaries (5 snapshots, latest commit d51641a)

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

Previous review (commit d51641a)

Status: 1 Issue Found | Recommendation: Address before merge

Overview

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

WARNING

File Line Issue
tinyagentos/app.py 1156 cluster_manager._gpu_arbiter = gpu_arbiter is a dead back-reference — the attribute is never read anywhere; eviction lives inside GpuArbiter and uses self._cluster_manager directly, so the wiring accomplishes nothing and misleads future readers.
Files Reviewed (2 changed files in incremental diff vs. 74b1185)
  • tinyagentos/app.py - 1 issue (dead _gpu_arbiter back-reference assignment)
  • tinyagentos/cluster/manager.py - 0 new issues (the corresponding _gpu_arbiter = None init at line 42 is the corollary of the same dead field; only one comment posted on the wiring site to avoid duplication)

Pre-existing findings from the previous review (tinyagentos/scheduler/gpu_arbiter.py lines 192, 246, 205; tests/test_gpu_arbiter_894.py lines 151, 406; and the older issues on unchanged files) are outside the scope of this incremental diff and remain unchanged.

Fix these issues in Kilo Cloud

Previous review (commit 74b1185)

Status: 3 Issues Found | Recommendation: Address before merge

Overview

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

CRITICAL

File Line Issue
tinyagentos/scheduler/gpu_arbiter.py 192 current = asyncio.current_task() captures the wrong task for the queued-admission path — when called via _drain_queue it returns the _process_queue task, so eviction cancels the entire queue processor.

WARNING

File Line Issue
tinyagentos/scheduler/gpu_arbiter.py 246 _evict_task mutates _running_tasks and calls asyncio_task.cancel() without holding _running_lock; the invariant between _running and _running_tasks is no longer enforced.

SUGGESTION

File Line Issue
tinyagentos/scheduler/gpu_arbiter.py 205 Log message "preempted via CancelledError" is misleading — CancelledError also fires on shutdown / unrelated cancellation, not only on eviction.
Files Reviewed (6 changed files at HEAD 74b1185)
  • tinyagentos/scheduler/gpu_arbiter.py - 3 issues (atomic-pop TOCTOU fix in _evict_task is correct; the three pre-existing findings on the queued-admission wrong-task, lock-free _running_tasks mutation, and misleading CancelledError log are all still valid against current HEAD and already posted as inline comments — no duplicates added)
  • tests/test_gpu_arbiter_894.py - 0 new issues (dead code at line 151 and BATCH=50 typo at line 406 still valid; already commented)
  • tinyagentos/scheduler/discovery.py - 0 new issues (4 pre-existing findings still valid; already commented)
  • tinyagentos/scheduler/types.py - 0 new issues (pre-existing finding still valid; already commented)
  • tinyagentos/app.py - 0 new issues (2 pre-existing findings still valid; already commented)
  • tinyagentos/scheduler/__init__.py - 0 new issues (export wiring, no defects)

The tests/test_gpu_arbiter.py file referenced in the original PR description was deleted in commit 4/4 and replaced with tests/test_gpu_arbiter_894.py. The two stale CRITICAL comments (id 3531827465, 3531827468) on the deleted file are now outdated; they should be dismissed.

Fix these issues in Kilo Cloud

Previous review (commit d193b46)

Status: 3 Issues Found | Recommendation: Address before merge

Overview

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

CRITICAL

File Line Issue
tinyagentos/scheduler/gpu_arbiter.py 192 current = asyncio.current_task() captures the wrong task for the queued-admission path — when called via _drain_queue it returns the _process_queue task, so eviction cancels the entire queue processor.

WARNING

File Line Issue
tinyagentos/scheduler/gpu_arbiter.py 246 _evict_task mutates _running_tasks and calls asyncio_task.cancel() without holding _running_lock; the invariant between _running and _running_tasks is no longer enforced.

SUGGESTION

File Line Issue
tinyagentos/scheduler/gpu_arbiter.py 205 Log message "preempted via CancelledError" is misleading — CancelledError also fires on shutdown / unrelated cancellation, not only on eviction.
Files Reviewed (1 changed file in this incremental commit)
  • tinyagentos/scheduler/gpu_arbiter.py - 3 issues (race-condition fix via atomic pop is correct; new _running_tasks registration + cancel introduces a critical bug on the queued-admission path)

Fix these issues in Kilo Cloud

Previous review (commit 52c6003)

Status: No New Issues Found | Recommendation: Re-review after addressing prior findings

Overview

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

CRITICAL

File Line Issue
(none this pass)

WARNING

File Line Issue
(none this pass)

SUGGESTION

File Line Issue
(none this pass)
Prior findings still unfixed (carry-over, already commented)

The fix commit (preemption via asyncio.Task cancellation) correctly addresses the
previous "_evict_task only cancels _arbiter_future, not the running task" bug.
All three findings from the prior review remain valid against the current HEAD
and are already posted as inline comments — no duplicates added:

File Line Severity Issue
tinyagentos/scheduler/gpu_arbiter.py 232 CRITICAL _evict_task checks _running membership and pops without acquiring _running_lock; races with _run_gpu_task's locked finally pop.
tests/test_gpu_arbiter_894.py 151 WARNING Dead code: arbiter._running_tasks.get("t-noevict", None) discards its return value.
tests/test_gpu_arbiter_894.py 406 SUGGESTION Comment typo: BATCH=50 should be BATCH=40 per the Priority enum.
Files Reviewed (4 changed files in this fix commit)
  • tinyagentos/scheduler/gpu_arbiter.py - 0 new issues (3 prior valid findings already commented)
  • tests/test_gpu_arbiter_894.py - 0 new issues (2 prior valid findings already commented)

Previous review (commit 1532fef)

Status: 3 New Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 1
WARNING 1
SUGGESTION 1

Incremental review against 1532fefb..95876da2. The fix commit (95876da2) correctly addresses the previous _evict_task not-preempting-running-tasks CRITICAL by tracking running asyncio tasks in self._running_tasks and cancelling them on eviction. New issues introduced by this commit:

  • CRITICAL race condition in the new _evict_task (unprotected _running pop vs locked _run_gpu_task finally)
  • WARNING dead code in the new tests/test_gpu_arbiter_894.py cleanup line
  • SUGGESTION comment typo (BATCH=50 vs actual BATCH=40) in the new test

All 10 previously-reported findings (4 CRITICAL on _probe_nvidia_vram, broken tests/test_gpu_arbiter.py, missing Resource(gpu_arbiter=) plumbing; 5 WARNINGs on _drain_queue break, victim_priority naming, gpu-cuda-N naming, 999_999 fallback, missing vram_probe in app.py; 2 SUGGESTIONs on payload(None) and estimated_vram_mb==0) remain unaddressed in changed files outside this commit's scope.

Issue Details (click to expand)

CRITICAL

File Line Issue
tinyagentos/scheduler/gpu_arbiter.py 232 _evict_task checks task_id not in self._running and then self._running.pop(task_id) without acquiring self._running_lock; _run_gpu_task's finally block holds the same lock for the same pop, so a natural task completion between lines 232–234 raises KeyError and crashes eviction.

WARNING

File Line Issue
tests/test_gpu_arbiter_894.py 151 arbiter._running_tasks.get("t-noevict", None) discards its result — dead code that looks like it should cancel the tracked asyncio task but doesn't.

SUGGESTION

File Line Issue
tests/test_gpu_arbiter_894.py 406 Comment says BATCH=50; the Priority enum defines BATCH = 40.
Files Reviewed (2 changed files in this commit)
  • tinyagentos/scheduler/gpu_arbiter.py - 1 issue (race in _evict_task)
  • tests/test_gpu_arbiter_894.py - 2 issues (dead code, comment typo)

Fix these issues in Kilo Cloud


Reviewed by hy3-20260706:free · Input: 84.8K · Output: 17.7K · Cached: 189.9K

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 7

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
tests/test_gpu_arbiter.py (1)

1-217: 🗄️ Data Integrity & Integration | 🔴 Critical | 🏗️ Heavy lift

Align this suite with the current GpuArbiter contract.
tinyagentos/scheduler/gpu_arbiter.py exposes submit_gpu/_check_admission/evict_lowest_priority and a stats() shape without allocations; it does not provide the VRAM-ledger API this file assumes (reserve, release, can_admit, find_eviction_candidates, evict_and_reserve, wait_for_vram, or VramAllocation). This file will fail at collection, and Resource.can_admit() here also doesn't apply a VRAM gate.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_gpu_arbiter.py` around lines 1 - 217, The test suite is written
against an older VRAM-ledger API that `GpuArbiter` no longer exposes, so update
`tests/test_gpu_arbiter.py` to match the current contract. Replace uses of
`reserve`, `release`, `can_admit`, `find_eviction_candidates`,
`evict_and_reserve`, `wait_for_vram`, and `VramAllocation` with assertions
around `submit_gpu`, `_check_admission`, `evict_lowest_priority`, and `stats()`.
Also remove or rewrite the `Resource.can_admit()` VRAM-specific expectations in
`TestResourceIntegration`, since that path no longer performs a VRAM gate in
this contract.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tinyagentos/app.py`:
- Around line 1144-1159: The GPU arbiter lifecycle is incomplete:
`GpuArbiter.start()` launches the `gpu-arbiter-queue` background task, but
`app.state.gpu_arbiter` is never stopped during shutdown. Update the shutdown
path to look up `app.state.gpu_arbiter` and call `await gpu_arbiter.stop()` with
exception handling and logging, using the existing `GpuArbiter` and
`app.state.gpu_arbiter` symbols to keep the cleanup consistent with the rest of
the app’s task-cancellation flow.
- Line 1155: Initialize app.state.gpu_arbiter to None in create_app’s eager
state-seeding block, matching the existing preseeded attributes like
beads_bridge and canvas_snapshotter. The gpu_arbiter assignment currently
happens only in the lifespan path, so add it alongside the other app.state
defaults and keep the later lifespan update that assigns the real gpu_arbiter
value.

In `@tinyagentos/scheduler/discovery.py`:
- Around line 190-231: The GPU backend filter in discovery is too narrow and
misses backends explicitly called out by the comment and admission logic. Update
gpu_backend_types in the GPU detection block inside the discovery routine so it
includes the image-generation backend types sd-cpp and sd-gpu, and also the CUDA
llama-cpp variant if it should participate in GPU scheduling; then make sure
_gpu_capabilities and _gpu_backend_for continue to recognize these backends when
building the ResourceSignature and routing by capability.
- Around line 216-218: The _gpu_vram_probe helper currently treats both a real
0-free GPU and a probe failure from _probe_nvidia_vram() as the same optimistic
999_999 value. Update _gpu_vram_probe so it preserves an actual zero-free result
as 0, and only returns the optimistic fallback when the probe is unavailable or
invalid, keeping the behavior localized to the GPU discovery path in
tinyagentos/scheduler/discovery.py.

In `@tinyagentos/scheduler/gpu_arbiter.py`:
- Around line 143-145: The eviction counter is being incremented twice for the
same queued task: once in _evict_task and again in submit_gpu’s
asyncio.CancelledError handler. Update submit_gpu (and the eviction path around
_evict_task) so only one of these locations records an eviction, and keep the
CancelledError block limited to cleanup/re-raise without bumping self._evicted
again.
- Around line 151-158: In `_check_admission()` within `GpuArbiter`, the current
`free_vram > 0` check conflates a saturated GPU with no probe data, allowing
admission when `free_vram == 0`; use the `_vram_probe()` total (or another
explicit presence signal) to distinguish an absent/unprobed GPU from a full GPU,
and only admit when a real GPU is present and VRAM is sufficient, otherwise
return a non-admitted `GpuAdmission` with the appropriate reason.
- Around line 206-230: Cancel the actual running coroutine when a task is
evicted, not just the queued submission future. Update evict_lowest_priority()
and _evict_task() in gpu_arbiter.py so that, after removing the task from
_running and releasing the lease, they also cancel the task object or its
running handle created by _run_gpu_task(), since _arbiter_future only covers
queued submissions. Make sure the eviction path handles both queued and
already-admitted tasks consistently.

---

Outside diff comments:
In `@tests/test_gpu_arbiter.py`:
- Around line 1-217: The test suite is written against an older VRAM-ledger API
that `GpuArbiter` no longer exposes, so update `tests/test_gpu_arbiter.py` to
match the current contract. Replace uses of `reserve`, `release`, `can_admit`,
`find_eviction_candidates`, `evict_and_reserve`, `wait_for_vram`, and
`VramAllocation` with assertions around `submit_gpu`, `_check_admission`,
`evict_lowest_priority`, and `stats()`. Also remove or rewrite the
`Resource.can_admit()` VRAM-specific expectations in `TestResourceIntegration`,
since that path no longer performs a VRAM gate in this contract.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: a6cd67dd-4e34-47a5-b87e-3841f3ec9391

📥 Commits

Reviewing files that changed from the base of the PR and between 9b2d1c3 and 1532fef.

📒 Files selected for processing (6)
  • tests/test_gpu_arbiter.py
  • tinyagentos/app.py
  • tinyagentos/scheduler/__init__.py
  • tinyagentos/scheduler/discovery.py
  • tinyagentos/scheduler/gpu_arbiter.py
  • tinyagentos/scheduler/types.py

Comment thread tinyagentos/app.py
Comment on lines +1144 to +1159

# Build the GPU arbiter — wraps the resource scheduler with VRAM-accounted
# admission control, queuing, and eviction for GPU-bound workloads.
try:
gpu_arbiter = GpuArbiter(
scheduler=resource_scheduler if resource_scheduler is not None else None,
cluster_manager=cluster_manager,
max_queue_size=100,
eviction_enabled=True,
)
await gpu_arbiter.start()
app.state.gpu_arbiter = gpu_arbiter
logger.info("GPU arbiter ready (queue size=100, eviction=enabled)")
except Exception:
logger.exception("GPU arbiter failed to start — GPU tasks will use vanilla scheduler")
app.state.gpu_arbiter = None

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

gpu_arbiter is started but never stopped on shutdown.

gpu_arbiter.start() spawns the gpu-arbiter-queue background task, but it is not registered in app.state._background_tasks and no await gpu_arbiter.stop() appears in the shutdown block (Lines 1280-1433). The queue-processor task is therefore never cancelled at shutdown — the same class of non-cancelled/non-daemon straggler this file elsewhere works hard to avoid (see the cancel_and_wait and BaseStore-backstop comments).

Add a stop in the shutdown path:

🔒 Proposed shutdown cleanup
_gpu_arbiter = getattr(app.state, "gpu_arbiter", None)
if _gpu_arbiter is not None:
    try:
        await _gpu_arbiter.stop()
    except Exception:
        logger.exception("gpu arbiter stop failed")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tinyagentos/app.py` around lines 1144 - 1159, The GPU arbiter lifecycle is
incomplete: `GpuArbiter.start()` launches the `gpu-arbiter-queue` background
task, but `app.state.gpu_arbiter` is never stopped during shutdown. Update the
shutdown path to look up `app.state.gpu_arbiter` and call `await
gpu_arbiter.stop()` with exception handling and logging, using the existing
`GpuArbiter` and `app.state.gpu_arbiter` symbols to keep the cleanup consistent
with the rest of the app’s task-cancellation flow.

Comment thread tinyagentos/app.py
eviction_enabled=True,
)
await gpu_arbiter.start()
app.state.gpu_arbiter = gpu_arbiter

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Set app.state.gpu_arbiter = None eagerly in create_app.

gpu_arbiter is only assigned inside the lifespan. Other lifespan-created attributes (e.g. beads_bridge, canvas_snapshotter at Lines 1543-1544) are pre-seeded to None in the eager block precisely so attribute-existence checks work during the pre-startup window and in tests that don't run the lifespan. gpu_arbiter is missing from that block, so any route doing app.state.gpu_arbiter before startup (or under a lifespan-less test client) hits AttributeError instead of a graceful None.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tinyagentos/app.py` at line 1155, Initialize app.state.gpu_arbiter to None in
create_app’s eager state-seeding block, matching the existing preseeded
attributes like beads_bridge and canvas_snapshotter. The gpu_arbiter assignment
currently happens only in the lifespan path, so add it alongside the other
app.state defaults and keep the later lifespan update that assigns the real
gpu_arbiter value.

Comment on lines +190 to +231
# GPU (CUDA/ROCm/Vulkan/Metal), only if a healthy GPU-capable backend exists.
# GPU backends: vllm, llama-cpp (when built with CUDA), ollama (GPU mode),
# exo, mlx (Apple Silicon GPU). Also sd-cpp/sd-gpu for image-generation.
gpu_backend_types = {"vllm", "ollama", "exo", "mlx"}
gpu_backends = [
b for b in catalog.backends()
if b.status == "ok" and b.type in gpu_backend_types
]
gpu_info = getattr(hardware_profile, "gpu", None)
gpu_type = getattr(gpu_info, "type", None) if gpu_info else None
has_gpu_hardware = gpu_type not in (None, "", "none")

if gpu_backends or has_gpu_hardware:
gpu_count = 1 # Default single-GPU; multi-GPU is Phase 2
gpu_signature = ResourceSignature(
platform="cuda" if gpu_type in ("cuda", "nvidia") else (
"rocm" if gpu_type == "rocm" else (
"metal" if gpu_type == "apple" else "gpu"
)
),
runtime="cuda" if gpu_type in ("cuda", "nvidia") else (
"rocm" if gpu_type == "rocm" else "native"
),
runtime_version="",
)

def _gpu_vram_probe() -> int:
free, _total = _probe_nvidia_vram()
return free if free > 0 else 999_999 # optimistic

def _gpu_capabilities() -> set[str]:
caps: set[str] = set()
for b in catalog.backends():
if b.status == "ok" and b.type in gpu_backend_types:
caps |= b.capabilities
return caps

def _gpu_backend_for(capability: str):
for b in catalog.backends_with_capability(capability):
if b.type in gpu_backend_types:
return b.url
return None

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

gpu_backend_types omits the image-generation backends named in the comment.

The comment (Lines 190-192) and the PR objective both call out sd-cpp/sd-gpu (and CUDA llama-cpp) as GPU backends, but gpu_backend_types = {"vllm", "ollama", "exo", "mlx"} excludes them. As a result _gpu_capabilities/_gpu_backend_for will never surface image-generation on the GPU resource even though it is in GPU_POTENTIAL_CAPABILITIES, so GPU image-gen won't route through the admission path.

Confirm whether the image-gen backend types should be included here.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tinyagentos/scheduler/discovery.py` around lines 190 - 231, The GPU backend
filter in discovery is too narrow and misses backends explicitly called out by
the comment and admission logic. Update gpu_backend_types in the GPU detection
block inside the discovery routine so it includes the image-generation backend
types sd-cpp and sd-gpu, and also the CUDA llama-cpp variant if it should
participate in GPU scheduling; then make sure _gpu_capabilities and
_gpu_backend_for continue to recognize these backends when building the
ResourceSignature and routing by capability.

Comment on lines +216 to +218
def _gpu_vram_probe() -> int:
free, _total = _probe_nvidia_vram()
return free if free > 0 else 999_999 # optimistic

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
path = Path('tinyagentos/scheduler/discovery.py')
text = path.read_text()
lines = text.splitlines()
for start, end in [(1, 260)]:
    print(f'লines {start}-{min(end, len(lines))}')
    for i in range(start-1, min(end, len(lines))):
        print(f"{i+1:4d}: {lines[i]}")
PY

Repository: jaylfc/taOS

Length of output: 11510


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect symbols around the GPU probe helpers and any resource registration logic.
python3 - <<'PY'
from pathlib import Path
path = Path('tinyagentos/scheduler/discovery.py')
lines = path.read_text().splitlines()
for i, line in enumerate(lines, 1):
    if 'def _gpu_vram_probe' in line or '_probe_nvidia_vram' in line or 'Resource' in line or 'vram' in line:
        start = max(1, i-25)
        end = min(len(lines), i+40)
        print(f"\n--- context around line {i} ---")
        for j in range(start, end+1):
            print(f"{j:4d}: {lines[j-1]}")
PY

Repository: jaylfc/taOS

Length of output: 48442


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Search for the NVIDIA VRAM probe implementation and any documentation/comments
# describing what 0 or unknown means.
rg -n "_probe_nvidia_vram|gpu_vram_probe|vram" tinyagentos -S

Repository: jaylfc/taOS

Length of output: 24249


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Look for tests or docs around GPU resource discovery semantics.
rg -n "999_999|unknown|probe.*gpu|free.*vram|total.*vram|nvidia" . -S

Repository: jaylfc/taOS

Length of output: 50368


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read only the relevant file sections with line numbers.
python3 - <<'PY'
from pathlib import Path
path = Path('tinyagentos/scheduler/discovery.py')
lines = path.read_text().splitlines()
for start, end in [(1, 260)]:
    for i in range(start-1, min(end, len(lines))):
        print(f"{i+1:4d}: {lines[i]}")
PY

Repository: jaylfc/taOS

Length of output: 11498


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
path = Path('tinyagentos/scheduler/discovery.py')
lines = path.read_text().splitlines()

targets = ['def _probe_nvidia_vram', 'def _gpu_vram_probe', 'Resource(', 'vram']
for idx, line in enumerate(lines, 1):
    if any(t in line for t in targets):
        start = max(1, idx-20)
        end = min(len(lines), idx+35)
        print(f"\n### around line {idx}")
        for j in range(start, end+1):
            print(f"{j:4d}: {lines[j-1]}")
PY

Repository: jaylfc/taOS

Length of output: 16576


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the NVIDIA VRAM probe and how Resource.memory_probe is used.
python3 - <<'PY'
from pathlib import Path

for file_path, ranges in [
    ('tinyagentos/scheduler/gpu_arbiter.py', [(1, 90), (140, 180)]),
    ('tinyagentos/scheduler/resource.py', [(1, 260)]),
]:
    path = Path(file_path)
    lines = path.read_text().splitlines()
    print(f"\n### {file_path} ({len(lines)} lines)")
    for start, end in ranges:
        print(f"\n--- lines {start}-{min(end, len(lines))} ---")
        for i in range(start-1, min(end, len(lines))):
            print(f"{i+1:4d}: {lines[i]}")
PY

Repository: jaylfc/taOS

Length of output: 14635


Separate probe failure from real 0-free VRAM. tinyagentos/scheduler/discovery.py:216-218 maps both a saturated GPU and any probe failure (_probe_nvidia_vram() returns 0, 0) to 999_999, so the resource looks effectively unbounded under pressure. Return 0 for actual zero-free cases and only use the optimistic fallback when the probe is unavailable.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tinyagentos/scheduler/discovery.py` around lines 216 - 218, The
_gpu_vram_probe helper currently treats both a real 0-free GPU and a probe
failure from _probe_nvidia_vram() as the same optimistic 999_999 value. Update
_gpu_vram_probe so it preserves an actual zero-free result as 0, and only
returns the optimistic fallback when the probe is unavailable or invalid,
keeping the behavior localized to the GPU discovery path in
tinyagentos/scheduler/discovery.py.

Comment thread tinyagentos/scheduler/gpu_arbiter.py
Comment thread tinyagentos/scheduler/gpu_arbiter.py Outdated
Comment thread tinyagentos/scheduler/gpu_arbiter.py
@jaylfc

jaylfc commented Jul 6, 2026

Copy link
Copy Markdown
Owner

Thanks Hogne — read through the whole arbiter. Strong foundation: clean VRAM accounting, admission via the atomic cluster leases (#1680), a priority queue with a FIFO tiebreak, and good unit coverage of the primitives. All the bots passed it, but I dug into the concurrency and there are a few real gaps I'd want sorted before this goes on a live path. Note it's currently dormant — it's wired into app.py and the queue loop runs, but nothing calls submit_gpu() yet, so none of this is breaking anything today.

1. Eviction doesn't actually stop running GPU work (the core one)

_evict_task pops _running and cancels the waiter's _arbiter_future — but directly-admitted tasks never get an _arbiter_future, and even for queued tasks the _run_gpu_task coroutine keeps running to completion inside _drain_queue. So the GPU work finishes and the VRAM is never freed. For an arbiter whose stated purpose is "priority-based eviction to prevent concurrent-load driver crashes (Xid 62)," eviction that doesn't preempt is the headline feature not actually working. We'd need a real cancellation hook into the running task (or scope eviction explicitly to queued tasks only and rename/doc it as such).

2. Local-VRAM admission is TOCTOU

On the no-cluster path, _check_admission reads live nvidia-smi free VRAM with no reservation. Two concurrent submit_gpu calls can both pass admission before either's model has loaded — which is exactly the concurrent-load crash the arbiter exists to prevent. The cluster path is safe (atomic leases); the local single-GPU path isn't. It needs a reservation/accounting step so an in-flight-but-not-yet-loaded admission is subtracted from free VRAM.

3. Double lease-release

When a task is evicted while also completing, _evict_task releases the lease and then _run_gpu_task's finally releases it again. Idempotent release (#1680) means it's probably harmless, but the ownership is muddled — whoever evicts should be the sole releaser.

4. Queue processor serializes on the running task

await self._run_gpu_task(...) inside the 2s _drain_queue blocks the loop for the whole task duration, so only one queued task drains at a time and nothing can preempt while it's blocked. That may well be intended (serialize GPU work), but if so it's worth a comment, and it interacts with #1 (you can't evict-to-make-room while the drain is parked on an await).

5. Minor: inconsistent locking on _running

It's mutated under _running_lock in _run_gpu_task but read/popped without the lock in evict_lowest_priority/_evict_task/the snapshots. Safe under today's single-thread-no-await, but fragile if an await ever creeps into those paths.

None of this is a blind-merge for me because #1 undercuts the whole point, but it's also not a rewrite — the eviction semantics are your design call. Do you want to take #1 and #2 (the two that matter) yourself, or would you rather I take it over and push the fixes on top of your commit like we did with the lease PR (#1680)? Either's fine by me — happy to keep it dormant in the meantime. Nice work on the bones of it.

@hognek

hognek commented Jul 6, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review — exactly the kind of feedback that makes this worth doing. Created 5 follow-up tasks for all five points:

They'll get picked up by the worker pipeline and pushed as new commits on this branch.

One question on workflow while we're here: we've been keeping PRs as draft until CI passes, then marking ready — but the CI matrix never actually runs because the fork needs maintainer workflow approval (the action_required gate). Is that the intended flow, or should we be doing something differently? We want to make sure we're not adding friction on your side.

@jaylfc

jaylfc commented Jul 6, 2026

Copy link
Copy Markdown
Owner

Perfect, thanks for splitting all five into tasks — that's exactly right.

On the CI question: you've spotted a real friction point. Because your PRs come from a fork, GitHub gates the workflow runs behind maintainer approval (the action_required state), so the draft-until-CI-green flow stalls — the matrix never starts until I click approve. So:

  • Mark PRs ready-for-review when the code is done, not when CI is green. Waiting for CI on a fork PR is a deadlock, since CI won't run until I approve it anyway.
  • When it's ready, I'll approve the workflow run and review together. I'll keep an eye out for your ready PRs so approvals don't lag.
  • I'll also relax the repo's fork-PR setting to "require approval for first-time contributors" so your subsequent runs auto-start once you're past the first — that removes most of the friction going forward.

No changes needed on your side beyond flipping ready when the code's done. Appreciate you flagging it rather than letting it sit.

@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown

All contributors have signed the CLA ✍️ ✅
Posted by the CLA Assistant Lite bot.

@hognek

hognek commented Jul 7, 2026

Copy link
Copy Markdown
Contributor Author

I have read the CLA Document and I hereby sign the CLA

@hognek

hognek commented Jul 7, 2026

Copy link
Copy Markdown
Contributor Author

recheck

@hognek

hognek commented Jul 7, 2026

Copy link
Copy Markdown
Contributor Author

Review feedback fix: eviction preemption (#894)

The latest commit (95876da) addresses the review feedback at #1683#issuecomment-4897343798.

Problem: _evict_task only cancelled _arbiter_future, which:

  • Doesn't exist on directly-admitted tasks
  • Is already resolved for queued-then-admitted tasks
  • Doesn't stop the running _run_gpu_task coroutine

Fix: _run_gpu_task now registers its asyncio.Task in _running_tasks, and _evict_task cancels it directly. CancelledError propagates to the payload, the finally block cleans up VRAM/leases properly (with guard against double-release).

Tests: 9 new tests in tests/test_gpu_arbiter_894.py covering both admission paths, lease handling, eviction-disabled fallback, and priority victim selection. All 45 existing + 15 cluster drain tests still pass (69/69).

CI is awaiting approval (first-time contributor policy).

Comment thread tinyagentos/scheduler/gpu_arbiter.py Outdated
return self._evict_task(victim_id)

def _evict_task(self, task_id: str) -> int:
if task_id not in self._running:

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 condition: _evict_task checks task_id not in self._running (line 232) and then self._running.pop(task_id) (line 234) without acquiring self._running_lock. Meanwhile _run_gpu_task's finally block DOES hold the lock for the same pop. If a task completes naturally between line 232 and line 234, the .pop() here will raise KeyError, crashing eviction. The new preemption logic added in this commit (asyncio task cancel + lease release) widens the window where this race can fire. Fix by wrapping the check+pop in async with self._running_lock: (and making _evict_task async, or using a sync lock for _running).


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

assert "t-noevict" in arbiter._running

# Clean up
arbiter._running_tasks.get("t-noevict", None)

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: Dead code — arbiter._running_tasks.get("t-noevict", None) returns a value that is immediately discarded. It does not cancel the task. Cleanup actually happens on the next line via submit_coro.cancel(). This line is misleading (looks like it should be submit_coro = ... or arbiter._running_tasks["t-noevict"].cancel()) and should either be removed or made to actually cancel the tracked asyncio task.


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

evicted = arbiter.evict_lowest_priority()
assert evicted == 1

# Low-priority (BATCH=50) should be evicted, high (INTERACTIVE_USER=10) stays

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: Comment typo — BATCH=50 should be BATCH=40 per the Priority enum in tinyagentos/scheduler/types.py:30.


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

@hognek hognek force-pushed the feat/gpu-arbiter branch from 95876da to 52c6003 Compare July 7, 2026 00:15
@hognek

hognek commented Jul 7, 2026

Copy link
Copy Markdown
Contributor Author

I have read the CLA Document and I hereby sign the CLA

@hognek

hognek commented Jul 7, 2026

Copy link
Copy Markdown
Contributor Author

recheck

f"GPU lease claim failed for {resource_id} (task {task.id})"
)
lease_id = lease.lease_id
current = asyncio.current_task()

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: current = asyncio.current_task() captures the wrong task for the queued-admission path.

When _run_gpu_task is awaited from _drain_queue (line 272), which itself is awaited from _process_queue, asyncio.current_task() returns the _process_queue task — not a task dedicated to running the user's payload. Calling asyncio_task.cancel() on it in _evict_task (line 248) cancels the entire queue processor, killing all future task admission and draining.

For a queued task, eviction of t-queued should stop the user payload (so its _arbiter_future propagates CancelledError to the submitter), but the _process_queue loop must keep running to admit the next queued task. Instead, the queue processor dies and no further queued work is processed for the lifetime of the GpuArbiter.

The fix must capture a task that is specific to running the user's work (e.g. a freshly created asyncio.create_task that wraps task.payload(...) / _scheduler.submit(task)), not the caller's task.

The test test_evict_queued_admitted_task_cancels_submitter in tests/test_gpu_arbiter_894.py does not exercise the real _drain_queue path — it calls arbiter._run_gpu_task(...) directly, so the captured task is the test's own runner and this bug is hidden.


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

if lease_id is not None and self._cluster_manager is not None:
self._cluster_manager.release_lease(lease_id)
# Cancel the running asyncio Task — stops _run_gpu_task and frees VRAM
asyncio_task = self._running_tasks.pop(task_id, None)

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: _evict_task mutates self._running_tasks and calls asyncio_task.cancel() without holding self._running_lock.

_run_gpu_task's finally block (line 209) registers writes/deletes to both self._running and self._running_tasks under _running_lock. _evict_task pops _running atomically (good, fixes the prior TOCTOU) but then does self._running_tasks.pop(task_id, None) and asyncio_task.cancel() outside the lock.

While CPython dict ops are atomic under the GIL, the invariant — that task_id maps to the same task under both views for the duration of the eviction — is no longer enforced. A subtle interleaving with a concurrent _run_gpu_task for a re-used task.id could cancel a task that belongs to a different logical submission. Holding the lock around the read/cancel (or moving the cancel inside the finally-pop in _run_gpu_task via a sentinel) would keep the invariant explicit.


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

self._admitted += 1
return result
except asyncio.CancelledError:
logger.info("gpu-arbiter: task %s preempted via CancelledError (pri=%d, vram=%d)",

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: Log message "preempted via CancelledError" is misleading — CancelledError is not eviction-specific.

This handler is reached for any CancelledError raised inside _run_gpu_task's try, including:

  • shutdown via await self.stop(),
  • the parent task (e.g. the arbiter's caller) being cancelled for unrelated reasons,
  • asyncio task-group cancellations.

In all of these cases the log line will read as if an eviction happened, but _evicted was not incremented by _evict_task and no lease was released via the eviction path. Recommend distinguishing the eviction case (e.g. a flag set by _evict_task before asyncio_task.cancel()) from generic cancellation in the log message.


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

hognek added a commit to hognek/tinyagentos that referenced this pull request Jul 7, 2026
…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.
hognek added a commit to hognek/tinyagentos that referenced this pull request Jul 7, 2026
…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.
@jaylfc

jaylfc commented Jul 7, 2026

Copy link
Copy Markdown
Owner

Status check on the arbiter package: all five review findings are now covered across this PR plus #1705 (TOCTOU), #1706 (non-blocking drain + eviction), and #1707 (running-lock). Nice work, especially the double-release guard. One thing before I land the set: #1706 and #1707 currently conflict with dev. Could you rebase them onto the current #1683/#1705 stack so the whole thing applies as one clean sequence? I want to merge the arbiter as a single coherent package rather than piecemeal, so once those two are clean I'll review the set end to end and merge together.

@jaylfc

jaylfc commented Jul 7, 2026

Copy link
Copy Markdown
Owner

On the arbiter and cluster epic (#1683, #1689, #1690, #1705, #1706, #1707): I did a deep pass across all of these and they overlap heavily. #1689 re-adds the same gpu_arbiter.py as this PR and shares the #1706/#1707 file footprint, and #1690's drain/pause edits the same manager.py methods as #1689. Before I merge any of it, can we consolidate into one ordered stack? Concretely: this PR plus the #1705/#1706/#1707 fixes should own the arbiter core; close or rebase #1689 on top rather than have two PRs adding gpu_arbiter.py; then sequence #1690 after so its drain/pause builds on the arbiter's lease model.

Two things to fix while consolidating: cluster_manager._gpu_arbiter is never wired in app.py, so eviction is a no-op in production (it only works in tests because they pass the kwarg), and #1690's drain uses startswith(name) lease matching, which releases other workers' leases (the collision we fixed before, so use an exact worker-name compare). Once it is one clean sequence I will review the whole package end to end and merge together.

@hognek

hognek commented Jul 7, 2026

Copy link
Copy Markdown
Contributor Author

Agreed on all of it. Plan: make #1683 + #1705/#1706/#1707 the arbiter core as one ordered stack, evaluate #1689 and either close it or rebase only its unique pause/resume+detach bits on top [not a second gpu_arbiter.py], and sequence #1690 last so its drain/pause builds on the lease model. Will also fix the two real ones you caught — wiring cluster_manager._gpu_arbiter in app.py so eviction is not a prod no-op, and switching #1690 drain to exact worker-name lease matching instead of startswith. Ill ping here when the whole sequence is clean for your end-to-end pass.

hognek added a commit to hognek/tinyagentos that referenced this pull request Jul 7, 2026
…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.
hognek added a commit to hognek/tinyagentos that referenced this pull request Jul 7, 2026
…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)
hognek added a commit to hognek/tinyagentos that referenced this pull request Jul 7, 2026
…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.
hognek added 4 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.
@hognek hognek force-pushed the feat/gpu-arbiter branch from d193b46 to 74b1185 Compare July 7, 2026 10:50
hognek added a commit to hognek/tinyagentos that referenced this pull request Jul 7, 2026
…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)
hognek added a commit to hognek/tinyagentos that referenced this pull request Jul 7, 2026
…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.
@hognek

hognek commented Jul 7, 2026

Copy link
Copy Markdown
Contributor Author

Update on the arbiter package: #1689 is closed [it duplicated the gpu_arbiter core; its concurrency fixes are covered by #1706/#1707]. The remaining stack PRs — #1683, #1705, #1706, #1707, #1690 — are all individually MERGEABLE vs dev again [the #1706/#1707 dev conflicts are resolved]. Were landing your two flagged fixes now: wiring cluster_manager._gpu_arbiter in app.py [so eviction is not a prod no-op] and switching #1690 drain to exact worker-name lease matching. On merging as one coherent package: fully retargeting all the bases into a single sequence keeps exceeding our automation limits — since the PRs are individually clean, would you rather merge them in order [#1683#1705#1706#1707#1690] yourself, or should we formally retarget each PRs base onto the one below it? Either works on our end.

…t a prod no-op

Initialize _gpu_arbiter = None in ClusterManager.__init__ and wire
cluster_manager._gpu_arbiter = gpu_arbiter in app.py after arbiter
creation, matching the _capabilities pattern. Without this, the arbiter
is only reachable via app.state.gpu_arbiter and prod eviction paths
that go through the cluster manager are dead.
Comment thread tinyagentos/app.py
)
await gpu_arbiter.start()
app.state.gpu_arbiter = gpu_arbiter
cluster_manager._gpu_arbiter = gpu_arbiter # wire for eviction paths

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: cluster_manager._gpu_arbiter = gpu_arbiter is a dead back-reference — cluster_manager._gpu_arbiter is never read anywhere in the codebase (the new init at tinyagentos/cluster/manager.py:42 and this assignment are its only references). The eviction path the comment refers to lives inside GpuArbiter itself, which already holds self._cluster_manager and uses claim_lease/release_lease directly — there is no method on ClusterManager that consults _gpu_arbiter to evict or inspect running GPU work. Either delete both the assignment and the __init__ field, or add the missing read site (e.g. a ClusterManager.evict_gpu_work(...) that this attribute is meant to back) and document the actual consumer. As shipped, this is misleading wiring that invites future readers to assume the back-reference is functional.


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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🧹 Nitpick comments (2)
tests/test_gpu_arbiter_894.py (2)

188-208: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Simulated "queued" test never exercises the real dequeue path.

The docstring says "queued-then-admitted", but the test calls arbiter._run_gpu_task directly rather than going through submit_gpu + the actual _queue/_drain_queue consumption. This validates eviction on a task that was never truly queued, so preemption behavior for tasks dequeued via the background drain loop remains unverified by this suite.

Consider driving this test through submit_gpu with VRAM pressure so the task is genuinely queued and then admitted by _drain_queue, to close the coverage gap on the real production path.
[recommended_refactor]

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_gpu_arbiter_894.py` around lines 188 - 208, The
“queued-then-admitted” test is bypassing the real queueing flow by calling
arbiter._run_gpu_task directly, so it does not cover the actual submit_gpu ->
_queue -> _drain_queue path. Update the test to enqueue the task through
submit_gpu under VRAM pressure and let _drain_queue admit it, then verify
_evict_task behavior and cancellation on that real queued task using the
existing arbiter, runner_task, and payload_cancelled checks.

195-201: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Fixed sleep(0.1) for synchronization is flaky.

Waiting a fixed 100ms for the task to register in _running (vs. using an asyncio.Event as other tests in this file do) can be flaky under CI load, causing intermittent false failures.

♻️ Suggested fix: poll instead of a fixed sleep
-        runner_task = asyncio.create_task(runner())
-
-        # Give it a moment to register in _running
-        await asyncio.sleep(0.1)
-
-        assert "t-queued" in arbiter._running
+        runner_task = asyncio.create_task(runner())
+
+        # Poll until registered in _running (avoids fixed-sleep flakiness)
+        for _ in range(50):
+            if "t-queued" in arbiter._running:
+                break
+            await asyncio.sleep(0.01)
+        assert "t-queued" in arbiter._running
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_gpu_arbiter_894.py` around lines 195 - 201, The test in the runner
setup is relying on a fixed asyncio.sleep(0.1) to wait for the task to appear in
arbiter._running, which is flaky under load. Replace this synchronization in the
runner/running-state test with a deterministic wait strategy, such as polling
until "t-queued" is present or using an asyncio.Event pattern like the other
tests in this file. Keep the assertion against arbiter._running, but ensure the
test only proceeds once runner() has actually registered its task.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tinyagentos/scheduler/gpu_arbiter.py`:
- Around line 180-201: The GPU task bookkeeping in _run_gpu_task and the
eviction path does not preserve the evictable flag, so evict_lowest_priority()
can still cancel non-evictable work. Store evictable in the _running entry for
each task, update the running-task tuple shape consistently wherever it is read
or written, and change evict_lowest_priority() to consider only idle or
evictable tasks as valid victims. Make sure the same TaskTracker/_running
bookkeeping is used everywhere so submit_gpu()’s evictable argument is enforced
end to end.
- Around line 118-159: The admission path in submit_gpu and _check_admission is
not accounting for VRAM already reserved by other queued/running tasks, so two
submissions can be admitted against the same free budget. Update the GPU arbiter
to make admission and reservation atomic by protecting the free-VRAM check plus
reservation update with a single lock, and subtract any reserved VRAM before
deciding whether to admit. Use the existing submit_gpu, _check_admission, and
_running reservation bookkeeping to ensure reservations are recorded before
returning admission.
- Around line 47-55: The GPU queue entry in _QueuedGpuTask currently ignores
waiting time, so low-priority tasks can starve and there is no max_wait_ms
enforcement. Update the scheduling logic in gpu_arbiter.py to use queued_at for
aging and check max_wait_ms when selecting from the queue, likely in the
queue-pop/dispatch path around the GPU arbiter methods that consume
_QueuedGpuTask. Ensure the priority comparison still works, but boost or bypass
entries that have waited too long so older tasks eventually run, and
reject/expire tasks whose wait exceeds max_wait_ms.
- Around line 143-146: The queued GPU work is not being dropped when the
submitter’s waiting future is already cancelled, so stale entries can still be
admitted and run later; update the queue admission path in the arbiter logic
around _drain_queue and the submit/done handling to check the stored
_arbiter_future before scheduling work, and skip/remove entries whose future is
already cancelled or done. Apply the same guard in the related submit/queue code
referenced by the later block so cancelled callers do not leave executable work
behind.
- Around line 160-191: The cluster GPU admission result is selecting a worker,
but that chosen resource is not being preserved into the execution path, so
queued work reaches _run_gpu_task with resource_id unset and never claims a
lease. Update the admission/scheduling flow around the GPU arbiter so the
selected worker/resource_id is returned or carried forward from the admission
check, and ensure the queued path passes that same resource_id into
_run_gpu_task when invoking the lease claim logic. Verify the resource is
threaded through any helper used by the cluster queue path so claim_lease()
always receives the selected cluster resource.

---

Nitpick comments:
In `@tests/test_gpu_arbiter_894.py`:
- Around line 188-208: The “queued-then-admitted” test is bypassing the real
queueing flow by calling arbiter._run_gpu_task directly, so it does not cover
the actual submit_gpu -> _queue -> _drain_queue path. Update the test to enqueue
the task through submit_gpu under VRAM pressure and let _drain_queue admit it,
then verify _evict_task behavior and cancellation on that real queued task using
the existing arbiter, runner_task, and payload_cancelled checks.
- Around line 195-201: The test in the runner setup is relying on a fixed
asyncio.sleep(0.1) to wait for the task to appear in arbiter._running, which is
flaky under load. Replace this synchronization in the runner/running-state test
with a deterministic wait strategy, such as polling until "t-queued" is present
or using an asyncio.Event pattern like the other tests in this file. Keep the
assertion against arbiter._running, but ensure the test only proceeds once
runner() has actually registered its task.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 684d79bb-ffa2-441a-869f-d7c21f79aa19

📥 Commits

Reviewing files that changed from the base of the PR and between 1532fef and d51641a.

📒 Files selected for processing (7)
  • tests/test_gpu_arbiter_894.py
  • tinyagentos/app.py
  • tinyagentos/cluster/manager.py
  • tinyagentos/scheduler/__init__.py
  • tinyagentos/scheduler/discovery.py
  • tinyagentos/scheduler/gpu_arbiter.py
  • tinyagentos/scheduler/types.py
✅ Files skipped from review due to trivial changes (1)
  • tinyagentos/cluster/manager.py
🚧 Files skipped from review as they are similar to previous changes (4)
  • tinyagentos/scheduler/init.py
  • tinyagentos/scheduler/types.py
  • tinyagentos/scheduler/discovery.py
  • tinyagentos/app.py

Comment on lines +47 to +55
@dataclass(order=True)
class _QueuedGpuTask:
"""Internal queue entry, ordered by (priority, seq)."""
priority: int
seq: int
task: Task = field(compare=False)
required_vram_mb: int = field(compare=False)
evictable: bool = field(compare=False)
queued_at: float = field(default_factory=time.time, compare=False)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Implement aging and max_wait_ms enforcement.

The queue orders only by (priority, seq); queued_at is informational and there is no max_wait_ms path. Low-priority entries can starve indefinitely, which misses the Phase 2 acceptance requirements.

Also applies to: 79-86, 266-290

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tinyagentos/scheduler/gpu_arbiter.py` around lines 47 - 55, The GPU queue
entry in _QueuedGpuTask currently ignores waiting time, so low-priority tasks
can starve and there is no max_wait_ms enforcement. Update the scheduling logic
in gpu_arbiter.py to use queued_at for aging and check max_wait_ms when
selecting from the queue, likely in the queue-pop/dispatch path around the GPU
arbiter methods that consume _QueuedGpuTask. Ensure the priority comparison
still works, but boost or bypass entries that have waited too long so older
tasks eventually run, and reject/expire tasks whose wait exceeds max_wait_ms.

Comment on lines +118 to +159
async def submit_gpu(
self, task: Task, required_vram_mb: int = 0,
evictable: bool = False, resource_id: str | None = None,
) -> object:
self._submitted += 1
if required_vram_mb > 0:
admission = self._check_admission(task, required_vram_mb)
if not admission.admitted:
if self._queue.full():
self._dropped += 1
raise NoResourceAvailableError(
f"GPU arbiter queue full ({self._max_queue_size}), "
f"dropped task {task.id}"
)
self._seq += 1
entry = _QueuedGpuTask(
priority=int(task.priority), seq=self._seq, task=task,
required_vram_mb=required_vram_mb, evictable=evictable,
)
await self._queue.put(entry)
self._queued += 1
loop = asyncio.get_running_loop()
done: asyncio.Future = loop.create_future()
entry.task._arbiter_future = done # type: ignore[attr-defined]
try:
return await done
except asyncio.CancelledError:
self._evicted += 1
raise
return await self._run_gpu_task(task, required_vram_mb, evictable, resource_id)

def _check_admission(self, task: Task, required_vram_mb: int) -> GpuAdmission:
if required_vram_mb <= 0:
return GpuAdmission(admitted=True)
free_vram, _total = self._vram_probe()
if free_vram > 0:
if free_vram < required_vram_mb:
return GpuAdmission(
admitted=False, free_vram_mb=free_vram, required_vram_mb=required_vram_mb,
reason=f"insufficient local VRAM: need {required_vram_mb} MiB, have {free_vram} MiB free",
)
return GpuAdmission(admitted=True, free_vram_mb=free_vram, required_vram_mb=required_vram_mb)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🔴 Critical | 🏗️ Heavy lift

Make admission and local VRAM reservation atomic.

_check_admission() admits from probed free VRAM only; _running reservations are recorded later and never subtracted. Two GPU submissions can both pass admission against the same free VRAM budget before the driver reflects usage, defeating the arbiter’s OOM prevention goal. Track reserved VRAM under one lock and subtract it during admission/registration.

Also applies to: 192-196

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tinyagentos/scheduler/gpu_arbiter.py` around lines 118 - 159, The admission
path in submit_gpu and _check_admission is not accounting for VRAM already
reserved by other queued/running tasks, so two submissions can be admitted
against the same free budget. Update the GPU arbiter to make admission and
reservation atomic by protecting the free-VRAM check plus reservation update
with a single lock, and subtract any reserved VRAM before deciding whether to
admit. Use the existing submit_gpu, _check_admission, and _running reservation
bookkeeping to ensure reservations are recorded before returning admission.

Comment on lines +143 to +146
return await done
except asyncio.CancelledError:
self._evicted += 1
raise

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Skip queued work after the submitter is cancelled.

If the caller awaiting done is cancelled, the queue entry remains and _drain_queue() can later execute the GPU task even though no submitter can receive the result. Check the stored _arbiter_future before admission and drop entries that are already cancelled/done.

Also applies to: 266-275

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tinyagentos/scheduler/gpu_arbiter.py` around lines 143 - 146, The queued GPU
work is not being dropped when the submitter’s waiting future is already
cancelled, so stale entries can still be admitted and run later; update the
queue admission path in the arbiter logic around _drain_queue and the
submit/done handling to check the stored _arbiter_future before scheduling work,
and skip/remove entries whose future is already cancelled or done. Apply the
same guard in the related submit/queue code referenced by the later block so
cancelled callers do not leave executable work behind.

Comment thread tinyagentos/scheduler/gpu_arbiter.py
Comment on lines +180 to +201
async def _run_gpu_task(self, task: Task, required_vram_mb: int, evictable: bool, resource_id: str | None) -> object:
lease_id: str | None = None
if self._cluster_manager is not None and resource_id is not None:
lease = self._cluster_manager.claim_lease(
resource_id=resource_id, caller=task.submitter,
ttl_seconds=300, required_vram_mb=required_vram_mb,
)
if lease is None:
raise NoResourceAvailableError(
f"GPU lease claim failed for {resource_id} (task {task.id})"
)
lease_id = lease.lease_id
current = asyncio.current_task()
async with self._running_lock:
self._running[task.id] = (task, lease_id, int(task.priority), required_vram_mb)
if current is not None:
self._running_tasks[task.id] = current
try:
if self._scheduler is not None:
result = await self._scheduler.submit(task)
else:
result = await task.payload(None)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Respect evictable before cancelling running GPU work.

evictable is accepted by submit_gpu() but never stored in _running; evict_lowest_priority() can therefore cancel any running task, including non-evictable foreground work. Store the flag with the running entry and filter victims to idle/evictable work only.

Also applies to: 220-248

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tinyagentos/scheduler/gpu_arbiter.py` around lines 180 - 201, The GPU task
bookkeeping in _run_gpu_task and the eviction path does not preserve the
evictable flag, so evict_lowest_priority() can still cancel non-evictable work.
Store evictable in the _running entry for each task, update the running-task
tuple shape consistently wherever it is read or written, and change
evict_lowest_priority() to consider only idle or evictable tasks as valid
victims. Make sure the same TaskTracker/_running bookkeeping is used everywhere
so submit_gpu()’s evictable argument is enforced end to end.

@jaylfc

jaylfc commented Jul 7, 2026

Copy link
Copy Markdown
Owner

Thanks for consolidating. Closing #1689 and confirming the stack is individually mergeable vs dev is exactly what I wanted. Yes, merging in dependency order works for me. Once your two fixes land (the app.py cluster_manager._gpu_arbiter wiring, and the exact worker-name lease matching in #1690), I will review the whole set end to end (#1683, #1705, #1706, #1707, #1690) and then merge them in that order. I want the full-set review first so I can confirm all five findings are covered and consistent before anything lands, but the strict-order merge itself is fine and avoids the base-retarget automation limits. Ping me when the two fixes are pushed.

@hognek

hognek commented Jul 7, 2026

Copy link
Copy Markdown
Contributor Author

Both fixes are pushed and ready for your end-to-end review: [1] cluster_manager._gpu_arbiter is now wired in app.py [commit on #1683: fix(cluster): wire gpu_arbiter into cluster_manager], so eviction is no longer a prod no-op; [2] #1690 drain now uses an exact worker-name compare instead of startswith. The full set — #1683, #1705, #1706, #1707, #1690 — is individually mergeable vs dev and ready for the whole-set review + strict-order merge whenever you have a window. Thanks!

@jaylfc

jaylfc commented Jul 7, 2026

Copy link
Copy Markdown
Owner

Did the full end-to-end review of the stack (#1683, #1705, #1706, #1707, plus #1690). Solid foundation and the five findings are all mechanically addressed, but the composed state has consistency bugs that block merging as a package. CI being green is misleading here: CodeRabbit never actually reviewed #1705/#1706/#1707 (rate-limited), so Kilo is the only real reviewer and it flagged most of these. Blockers, per PR:

#1707 (the big one): evict_lowest_priority became async here but the call site in _drain_queue (gpu_arbiter.py around :378 and :383) still calls it synchronously. At runtime that returns a coroutine, if evicted > 0 raises TypeError, and since _process_queue only catches CancelledError the whole queue-processor task dies permanently on the first contended drain. Add the awaits.

#1706: the post-eviction re-admit calls _check_admission instead of _reserve_and_check, so it spawns a task with no reservation entry and re-opens the TOCTOU window plus drifts the _reserved_vram_mb accounting.

#1705: the lease claim raises before the try/finally, so a failed claim never releases the reservation and _reserved_vram_mb leaks until restart. Move the claim inside the try/finally. Also the cluster-mode admission path does not subtract reserved VRAM, so finding #2 is only closed for the local path.

#1690: the new /drain, /cancel-drain and /update endpoints have no auth, unlike the sibling cluster routes. Any LAN client can drain or offline a worker or trigger a remote worker update/restart. Please gate them with _require_admin (and the manual-claim rate limiter). Also the force-release path mutates self._leases outside _lease_lock while the sweep runs under it, which can race.

Cross-cutting (most important for the epic gate): wiring cluster_manager._gpu_arbiter in app.py is in, but nothing actually calls it. drain_worker(graceful=False) and the monitor stale-drain branch force-release the cluster lease but never ask the arbiter to cancel the running task, and nothing anywhere calls submit_gpu, so the arbiter is constructed, started, and idle. Until the drain path notifies the arbiter and a real admission path routes through submit_gpu, eviction is still a prod no-op regardless of the app.py wiring.

Minimum to unblock the merge-in-order: the #1707 awaits, the #1690 auth, the #1705 reservation-release, and the drain to arbiter wiring. Once those are in I will re-review the composed state and merge the stack in order. Happy to pair on the wiring piece if useful.

hognek added a commit to hognek/tinyagentos that referenced this pull request Jul 7, 2026
…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.
@hognek

hognek commented Jul 7, 2026

Copy link
Copy Markdown
Contributor Author

Update on the arbiter blockers — all addressed, but a heads-up on structure. The fixes [#1707 async drain awaits, #1705 reservation-release inside try/finally, #1706 TOCTOU re-admit via _reserve_and_check, #1690 auth + _lease_lock on force-release, and the cross-cutting drain->arbiter task-cancellation wiring] landed CONSOLIDATED on one rebased branch — PR #1718 [feat/worker-auto-update-rebased, currently mergeable, Kilo findings addressed] — rather than distributed across the individual stack branches. That does not match your merge-the-stack-in-order plan.

Your call:
[a] review + merge #1718 as the consolidated arbiter package [it is mergeable and covers all five findings], or
[b] we distribute the fixes back onto #1705/#1706/#1707/#1690 individually so you can merge #1683 -> #1705 -> #1706 -> #1707 -> #1690 in dependency order as you wanted.
[b] is more work on our end but matches your requested structure. Which do you prefer? [Still happy to pair on the wiring if useful.]

@jaylfc

jaylfc commented Jul 8, 2026

Copy link
Copy Markdown
Owner

SET review + the path to merge is now consolidated on #1718 (the canonical superset that folds this PR in). See my comment there. Keeping the epic on HOLD until the gate-closer (a real submit_gpu admission path) + the #1725/#1726 findings land; #1683 will close as superseded by #1718.

… GpuArbiter

Add VramReservationManager as the arbiter's INTERNAL reservation
layer.  Replaces ad-hoc vram_probe with atomic check-and-reserve
under an asyncio.Lock so the arbiter is the single VRAM authority.

- VramReservationManager: atomic reserve()/release(), sync available()
  for best-effort admission checks, stats() for observability
- GpuArbiter._check_admission now reads against reserved VRAM
- GpuArbiter._run_gpu_task atomically reserves local VRAM before
  execution and releases in finally (normal completion) or via
  _evict_task (eviction path)
- Cluster lease path unchanged (VRAM tracked by lease system)
- Exported via scheduler.__init__ lazy loading
- 21 new tests: 11 unit (VramReservationManager), 9 integration
  (arbiter lifecycle), 1 lazy-export test
- 59/59 related tests pass (arbiter + cluster + scheduler + vram)
@hognek hognek closed this Jul 9, 2026
@hognek hognek deleted the feat/gpu-arbiter branch July 9, 2026 09:59
# read; the actual reserve happens atomically in _run_gpu_task).
free_vram = self._vram_reservations.available("local")
_free_raw, total = self._vram_probe()
if total > 0:

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: Admission no longer falls through to the cluster path when the local GPU is fully reserved

Previously the gate was if free_vram > 0; it is now if total > 0. With a local GPU present (total > 0) but fully reserved (free == 0), a VRAM-needing task now returns admitted=False and never reaches the self._cluster_manager cluster-worker check below. In a mixed deployment (local GPU + cluster workers) this wrongly rejects work that could run on a cluster worker whenever local VRAM is exhausted. If cluster fallback is intended, gate on whether the task targets local vs cluster (e.g. resource_id) or restore the fall-through when local VRAM is insufficient but total > 0.


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

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.

Scheduler Phase 2: VRAM-accounted admission + queue + eviction (the GPU arbiter both agents use)

2 participants