Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions tests/test_cluster.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,9 @@ async def test_register_worker(self):
async def test_unregister_worker(self):
mgr = ClusterManager()
await mgr.register_worker(_make_worker("gpu-box"))
assert mgr.unregister_worker("gpu-box") is True
assert await mgr.unregister_worker("gpu-box") is True
assert mgr.get_workers() == []
assert mgr.unregister_worker("gpu-box") is False
assert await mgr.unregister_worker("gpu-box") is False

async def test_heartbeat_updates_load_and_status(self):
mgr = ClusterManager()
Expand Down
124 changes: 124 additions & 0 deletions tests/test_leases.py
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,52 @@ async def test_claim_rejects_negative_vram(client, app):
assert resp.status_code == 422


# ── taOS #1705: DELETE /worker endpoint leases ─────────────────────────


@pytest.mark.asyncio
async def test_unregister_worker_releases_leases(client, app):
"""DELETE /api/cluster/workers/{name} releases all GPU leases held
by that worker (taOS #1705)."""
# Register a worker with enough VRAM
key = await _register_worker(
client, app, "gpu-node", "http://10.0.0.1:9000",
capabilities=["llm-chat"],
hardware={"gpu": {"model": "GTX 1080", "vram_mb": 8192}},
)
await _heartbeat(client, key, "gpu-node", free_vram_mb=8000, used_vram_mb=0)

# Claim two leases
claim1 = await client.post("/api/cluster/leases/claim", headers=_auth(app), json={
"resource_id": "gpu-node:gpu-cuda-0",
"caller": "test",
"ttl_seconds": 60,
})
assert claim1.status_code == 200

claim2 = await client.post("/api/cluster/leases/claim", headers=_auth(app), json={
"resource_id": "gpu-node:gpu-cuda-1",
"caller": "test",
"ttl_seconds": 60,
})
assert claim2.status_code == 200

# Verify both leases are present
list_resp = await client.get("/api/cluster/leases", headers=_auth(app))
assert list_resp.json()["count"] == 2

# Unregister the worker — this must release its leases
resp = await client.delete("/api/cluster/workers/gpu-node")
assert resp.status_code == 200
assert resp.json()["status"] == "removed"

# Verify all leases are gone
list_resp = await client.get("/api/cluster/leases", headers=_auth(app))
assert list_resp.json()["count"] == 0

await app.state.cluster_pairing.close()


# ── ClusterManager unit tests ──────────────────────────────────────────


Expand Down Expand Up @@ -530,3 +576,81 @@ async def test_claim_serialized_by_lease_lock(self):

result = await task
assert result is not None

# ── taOS #1705: unregister releases leases ─────────────────────────

async def test_unregister_releases_all_leases(self):
"""Unregistering a worker releases all of its active GPU leases."""
mgr = ClusterManager()
mgr._workers["gpu-node"] = _worker("gpu-node", free_vram=8000)

# Claim several leases
lease1 = await mgr.claim_lease("gpu-node:gpu-cuda-0", caller="test")
lease2 = await mgr.claim_lease("gpu-node:gpu-cuda-1", caller="test")
assert lease1 is not None
assert lease2 is not None
assert len(mgr.get_leases()) == 2

# Unregister — all leases must be gone
assert await mgr.unregister_worker("gpu-node") is True
assert len(mgr.get_leases()) == 0
assert lease1.lease_id not in mgr._leases
assert lease2.lease_id not in mgr._leases

async def test_unregister_unknown_worker_noop(self):
"""Unregistering an unknown worker returns False and touches no leases."""
mgr = ClusterManager()
mgr._workers["gpu-node"] = _worker("gpu-node", free_vram=8000)
await mgr.claim_lease("gpu-node:gpu-cuda-0", caller="test")
assert len(mgr.get_leases()) == 1

assert await mgr.unregister_worker("ghost") is False
assert len(mgr.get_leases()) == 1 # existing lease untouched

async def test_unregister_one_worker_spares_another(self):
"""Unregistering worker A leaves worker B's leases untouched."""
mgr = ClusterManager()
mgr._workers["gpu-a"] = _worker("gpu-a", free_vram=8000)
mgr._workers["gpu-b"] = _worker("gpu-b", free_vram=8000)

lease_a = await mgr.claim_lease("gpu-a:gpu-cuda-0", caller="test")
lease_b = await mgr.claim_lease("gpu-b:gpu-cuda-0", caller="test")
assert lease_a is not None
assert lease_b is not None

assert await mgr.unregister_worker("gpu-a") is True
assert len(mgr.get_leases()) == 1
assert lease_b.lease_id in mgr._leases
assert lease_a.lease_id not in mgr._leases

async def test_reclaim_after_unregister(self):
"""After unregister releases leases, a new claim succeeds."""
mgr = ClusterManager()
mgr._workers["gpu-node"] = _worker("gpu-node", free_vram=8000)

await mgr.claim_lease("gpu-node:gpu-cuda-0", caller="first")
assert await mgr.unregister_worker("gpu-node") is True

# Re-register and claim again
mgr._workers["gpu-node"] = _worker("gpu-node", free_vram=8000)
lease = await mgr.claim_lease("gpu-node:gpu-cuda-0", caller="second")
assert lease is not None
assert lease.caller == "second"

async def test_unregister_releases_leases_atomically(self):

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: Test name claims "atomically" but only verifies lock-blocking, not actual atomicity

The test test_unregister_releases_leases_atomically only holds _lease_lock from the test side and asserts that the unregister coroutine is blocked. It does not actually race a concurrent claim_lease against unregister_worker to verify end-to-end atomicity (e.g., that a claim started just before unregister is either fully created-and-removed, or never created and not leaked).

Either rename the test to test_unregister_blocks_on_lease_lock for accuracy, or strengthen it to spawn a claim_lease task concurrently with unregister_worker and assert the post-state is consistent (no leaked lease, worker gone).


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

"""Unregister holds _lease_lock so concurrent claims see a
consistent view — they cannot race with the lease release."""
mgr = ClusterManager()
mgr._workers["gpu-node"] = _worker("gpu-node", free_vram=8000)

# Hold the lock so unregister blocks on it
async with mgr._lease_lock:
unregister_task = asyncio.create_task(
mgr.unregister_worker("gpu-node")
)
await asyncio.sleep(0.05)
assert not unregister_task.done()

# Lock released — unregister completes
assert await unregister_task
assert len(mgr.get_leases()) == 0
32 changes: 30 additions & 2 deletions tinyagentos/cluster/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -259,8 +259,36 @@ def heartbeat(
pass # No running loop (e.g. in sync tests) — skip gracefully
return True

def unregister_worker(self, name: str) -> bool:
return self._workers.pop(name, None) is not None
async def unregister_worker(self, name: str) -> bool:
"""Remove a worker and all of its active GPU leases.

When a worker is explicitly unregistered (admin action), every
active lease tied to the worker's resources is released so that
those resource slots become available for new claims immediately.
This mirrors the lease-release logic in ``_monitor_loop`` for the
heartbeat-timeout path (taOS #1705).
"""
worker = self._workers.get(name)
if worker is 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.

SUGGESTION: Unused local variable worker

The result of self._workers.get(name) is stored in worker but never read. Replace the two-line check with a single membership test:

        if name not in self._workers:
            return False

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

return False

# Release all active leases for this worker's resources.
async with self._lease_lock:
lids: list[str] = [
lid for lid, lease in self._leases.items()
if (parsed := self._parse_resource_id(lease.resource_id))
and parsed[0] == name
]
for lid in lids:
self._leases.pop(lid, None)
logger.debug(
"Lease %s released — worker '%s' unregistered",
lid, name,
)

self._workers.pop(name, None)
logger.info("Worker '%s' unregistered — %d leases released", name, len(lids))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

WARNING: Worker pop happens outside _lease_lock — operation is not visibly atomic w.r.t. concurrent claims

The self._workers.pop(name, None) call runs after the async with self._lease_lock: block has already exited. While no await currently sits between the lock release and the pop (so pure-asyncio preemption won't fire here today), this is a fragile pattern:

  • Any future await added inside unregister_worker between these two lines (e.g., for logging, metrics, or a callback) would open a window where a concurrent claim_lease can see the worker still registered, create a new lease, and have that lease silently leaked — the exact bug this PR is fixing.
  • The docstring advertises "remove a worker and ALL of its active GPU leases" — the implementation should match that contract visibly.

Move self._workers.pop(name, None) inside the async with self._lease_lock: block, alongside the lease release.


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

return True
Comment on lines +271 to +291

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the relevant file and surrounding symbols.
git ls-files tinyagentos/cluster/manager.py tests/test_cluster.py tests/test_leases.py

printf '\n== manager.py outline ==\n'
ast-grep outline tinyagentos/cluster/manager.py --view expanded || true

printf '\n== unregister_worker implementation ==\n'
sed -n '230,290p' tinyagentos/cluster/manager.py

printf '\n== lease lock usages ==\n'
rg -n "_lease_lock|unregister_worker|claim_lease|_workers\.pop|_workers\.get" tinyagentos/cluster/manager.py

printf '\n== relevant tests ==\n'
sed -n '1,260p' tests/test_cluster.py
printf '\n--- test_leases ---\n'
sed -n '1,260p' tests/test_leases.py

Repository: jaylfc/taOS

Length of output: 25349


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '== worker lock / mutations ==\n'
rg -n "_workers\.|_worker_lock|asyncio\.Lock|register_worker|unregister_worker" tinyagentos/cluster/manager.py tinyagentos/cluster -g '*.py'

printf '\n== monitor loop lease-release block ==\n'
sed -n '520,560p' tinyagentos/cluster/manager.py

printf '\n== API routes touching unregister ==\n'
rg -n "unregister_worker|/workers" tinyagentos -g '*.py'

Repository: jaylfc/taOS

Length of output: 8345


Make unregister_worker atomic with the lease release. _workers isn’t protected anywhere else, so two concurrent calls can both pass the pre-check and both report success. Move the existence check and pop() into the same _lease_lock section as the lease cleanup.

🤖 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/cluster/manager.py` around lines 255 - 275, Make
`unregister_worker` atomic by moving the `_workers` existence check and `pop()`
into the same `_lease_lock` block used for lease cleanup, so concurrent callers
cannot both pass the pre-check and both report success. Update
`ClusterManager.unregister_worker` to check `self._workers.get(name)` only while
holding `_lease_lock`, release matching leases, remove the worker from
`_workers`, and then log the final lease count from the locked section.


def get_workers(self) -> list[WorkerInfo]:
return list(self._workers.values())
Expand Down
2 changes: 1 addition & 1 deletion tinyagentos/routes/cluster.py
Original file line number Diff line number Diff line change
Expand Up @@ -515,7 +515,7 @@ async def worker_heartbeat(request: Request, body: HeartbeatBody):
@router.delete("/api/cluster/workers/{name}")
async def unregister_worker(request: Request, name: str):
cluster = request.app.state.cluster_manager
removed = cluster.unregister_worker(name)
removed = await cluster.unregister_worker(name)
if not removed:
return JSONResponse({"error": "Worker not found"}, status_code=404)
return {"status": "removed", "name": name}
Expand Down
Loading