Skip to content

feat(cluster): worker auto-update with graceful drain, pause, install, restart (taOS #890)#1690

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

feat(cluster): worker auto-update with graceful drain, pause, install, restart (taOS #890)#1690
hognek wants to merge 4 commits into
jaylfc:devfrom
hognek:feat/worker-auto-update

Conversation

@hognek

@hognek hognek commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Summary

Implements worker auto-update with graceful pause/drain/restart for taOS cluster workers (taOS #890).

Skald workers on taOS nodes can now update without dropping inflight embedding or extraction jobs. The controller orchestrates the sequence: pause incoming routing → drain inflight leases → trigger deploy update-worker → worker restarts → re-registers → routing resumes.

Changes

ClusterManager (tinyagentos/cluster/manager.py)

  • drain_worker(name, graceful=True): Worker enters "draining" status, excluded from routing, catalog, and lease claims. When graceful=False, all leases are force-released and worker is marked offline immediately.
  • cancel_drain(name): Returns a draining worker back to "online".
  • Monitor loop: Auto-completes drains when all leases are released; force-finishes stale drains on heartbeat timeout.
  • Routing/catalog: get_workers_for_capability, aggregate_catalog, and _worker_for_resource all exclude draining workers.

Routes (tinyagentos/routes/cluster.py)

  • POST /api/cluster/workers/{name}/drain — Begin graceful or force drain
  • POST /api/cluster/workers/{name}/cancel-drain — Cancel an in-progress drain
  • POST /api/cluster/workers/{name}/update — Full orchestrated auto-update: drain → deploy update-worker → (worker restarts, re-registers, resumes routing)

Tests (tests/test_cluster.py)

  • 9 new tests covering: graceful drain, force drain, cancel drain, routing exclusion, catalog exclusion, lease claim exclusion
  • All 191 cluster-related tests pass

Flow

POST /api/cluster/workers/{name}/update
  ├── drain_worker(name, graceful=True) → status="draining"
  ├── POST {worker.url}/api/worker/deploy {"command": "update-worker"}
  │     └── Worker: git pull → pip install → restart
  ├── On failure: cancel_drain() so worker can still serve traffic
  └── On success: monitor loop auto-completes drain when worker re-registers

Summary by CodeRabbit

  • New Features

    • Added admin-only endpoints to drain workers with graceful or forced options, cancel an in-progress drain, and run a controlled worker update flow.
    • Update performs a graceful drain first, then triggers the remote deploy and returns a combined status payload.
  • Bug Fixes

    • Draining/offline workers are excluded from routing and the aggregate catalog, and lease claims are blocked while draining.
    • Draining workers are automatically completed to offline when leases are released, or forced offline after a heartbeat timeout.
    • Drain and update failures return appropriate conflict/not-found/502 responses and may auto-cancel the drain.

…, restart

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

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

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

Tests: 9 new tests (25 total in test_cluster.py), 191 cluster tests pass.
@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds graceful and forced worker draining, excludes draining workers from cluster selection, auto-completes drains in the monitor loop, exposes drain and update endpoints, and tests lifecycle and deployment failure behavior.

Changes

Worker Drain Lifecycle

Layer / File(s) Summary
Drain state and worker eligibility
tinyagentos/cluster/manager.py
Adds drain and cancellation methods, force lease release, and excludes draining or offline workers from lease resolution and catalog aggregation.
Automatic drain completion
tinyagentos/cluster/manager.py
The monitor loop processes worker snapshots, marks lease-free draining workers offline, and force-completes timed-out drains.
Drain and update HTTP orchestration
tinyagentos/routes/cluster.py
Adds admin-only drain, cancel-drain, and update endpoints with remote deployment, failure classification, rollback, and restart-disconnect handling.
Drain and update behavior validation
tests/test_cluster.py, tests/test_routes_cluster.py
Tests lifecycle transitions, worker exclusion, endpoint validation, authorization, deployment failures, and disconnect behavior.

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

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant ClusterRoute
  participant ClusterManager
  participant RemoteWorker
  participant MonitorLoop

  Client->>ClusterRoute: POST worker update
  ClusterRoute->>ClusterManager: drain_worker
  ClusterManager-->>ClusterRoute: draining status
  ClusterRoute->>RemoteWorker: deploy update-worker
  alt deploy failure
    RemoteWorker-->>ClusterRoute: error
    ClusterRoute->>ClusterManager: cancel_drain
    ClusterManager-->>ClusterRoute: online status
    ClusterRoute-->>Client: 502 failure
  else deploy succeeds or restart disconnect
    RemoteWorker-->>ClusterRoute: deployment result
    ClusterRoute-->>Client: update response
    MonitorLoop->>ClusterManager: inspect draining worker
    ClusterManager-->>MonitorLoop: complete or timeout drain
  end
Loading

Possibly related issues

  • jaylfc/taOS issue 890: Covers the graceful drain and worker update coordination implemented here.

Possibly related PRs

  • jaylfc/taOS#1680: Intersects with this PR’s lease claim and worker-status eligibility changes.
  • jaylfc/taOS#1687: Shares the GPU lease coordination and worker status filtering paths.
  • jaylfc/taOS#1711: Overlaps with lease claiming and status rechecks in ClusterManager.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly reflects the main change: cluster worker auto-update with graceful drain and restart handling.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ 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.

@gitar-bot

gitar-bot Bot commented Jul 6, 2026

Copy link
Copy Markdown

Gitar is working

Gitar

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

Copy link
Copy Markdown

Qodo reviews are paused for this user.

Troubleshooting steps vary by plan Learn more →

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

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

Comment thread tinyagentos/cluster/manager.py Outdated
# Force-release all leases for this worker
lids = [
lid for lid, lease in self._leases.items()
if lease.resource_id.startswith(name + ":")

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: Worker name prefix-match collision

lease.resource_id.startswith(name + ":") reintroduces the exact bug the existing monitor-loop code (lines 604-610) was fixed to avoid — see the comment immediately above this hunk: "Match on the exact worker name (not a resource_id prefix) so 'gpu-node' and 'gpu-node-2' don't collide." A force-drain of gpu-node will release leases belonging to gpu-node-2. Use _parse_resource_id() and compare parsed[0] == name like the offline path does.

Suggested change
if lease.resource_id.startswith(name + ":")
if (parsed := self._parse_resource_id(lease.resource_id)) and parsed[0] == name

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

Comment thread tinyagentos/cluster/manager.py Outdated
if lease.resource_id.startswith(name + ":")
]
for lid in lids:
self._leases.pop(lid, 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: Lease mutations bypass _lease_lock

The class docstring (lines 35-38) states all lease-table mutations are serialized via self._lease_lock. This sync method mutates self._leases directly without acquiring it, while _sweep_expired_leases, _monitor_loop, and other lease-mutating paths all hold the lock. Concurrent force-drain during a claim_lease or sweep can corrupt iteration. Wrap the release loop in async with self._lease_lock: — or make drain_worker async and await it from the route.


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

Comment thread tinyagentos/cluster/manager.py Outdated
f"{'Tasks will complete before detach.' if graceful else 'All leases released immediately.'}"
)
try:
asyncio.get_running_loop().create_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.

WARNING: Fire-and-forget notification task not retained

asyncio.get_running_loop().create_task(...) returns a task object that this code drops on the floor. The class already maintains self._background_tasks: set[asyncio.Task] (line 46) for exactly this purpose — add the task to it so the notification coroutine isn't GC'd mid-flight if the event loop runs a GC cycle before it completes.


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

Comment thread tinyagentos/cluster/manager.py Outdated
elif worker.status == "draining":
active_leases = [
lid for lid, lease in self._leases.items()
if lease.resource_id.startswith(worker.name + ":")

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: Same prefix-collision bug in monitor drain path

Line 620 (and 639) repeat the lease.resource_id.startswith(worker.name + ":") pattern. A draining gpu-node whose leases actually belong to gpu-node-2 will incorrectly auto-complete the drain (line 622) and then mark gpu-node-2's owner offline when the drain timer fires (line 635-642). Use _parse_resource_id and compare parsed[0] == worker.name, matching the offline path above.


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

return result


@router.post("/api/cluster/workers/{name}/update")

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: Destructive endpoints have no authentication/authorization

/drain, /cancel-drain, and /update can take down arbitrary cluster workers and force-release their inflight GPU leases. If the rest of the cluster API requires auth (look at neighboring routes in this file), these endpoints must too. At minimum, restrict them to an admin role and add rate limiting — an attacker or a buggy automation hitting /update in a loop will churn the whole cluster.


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

Comment thread tinyagentos/routes/cluster.py Outdated
worker = cluster.get_worker(name)
if not worker:
return JSONResponse({"error": f"Worker '{name}' not found"}, status_code=404)
if worker.status not in ("online", "draining"):

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: No idempotency / re-entry guard on update

Allowing update when worker.status == "draining" means a second concurrent call (or a retry) silently kicks off another update-worker deploy while the first is still in flight. Combined with the cancel-on-failure logic at line 1179, two simultaneous calls can race: one cancels the other's drain, or both POST update-worker to a worker that is about to restart. Track an in-flight updating flag (or an update_token) on the worker and 409 on duplicate.


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

f"{worker.url}/api/worker/deploy",
json={"command": "update-worker"},
)
deploy_result = resp.json()

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: HTTP response status unchecked before parsing

resp.json() is called regardless of resp.status_code. If the worker returns 5xx with an HTML or plain-text error page (very likely during a deploy that's about to kill the process), the JSON parser raises, the broad except Exception fires, and the route cancels the drain — leaving a worker that actually accepted the update-worker command and is mid-restart. The handler returns 502 to the caller even though the update is proceeding. Check resp.raise_for_status() first, and on failure don't cancel the drain if status_code suggests the request was received (e.g. 5xx after the deploy was triggered).


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

json={"command": "update-worker"},
)
deploy_result = resp.json()
except Exception as exc:

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: except Exception blindly cancels drain

The handler catches every exception (network errors, JSON parse errors, timeouts) and unconditionally calls cluster.cancel_drain(name). But the drain may have been started independently by a prior /drain call from another operator — cancelling it here wipes their intent. Cancel only when this route initiated the drain (track that via a flag on the worker or a return value from drain_worker). Also narrow the except to httpx.HTTPError so programming bugs surface.


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

Comment thread tinyagentos/routes/cluster.py Outdated
cluster = request.app.state.cluster_manager
result = cluster.cancel_drain(name)
if "error" in result:
return JSONResponse(result, status_code=404)

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: Wrong status code for cancel-drain error

cancel_drain returns {"error": "worker is not draining (status=...)"} — a state-conflict error, not a missing resource. The route returns 404, but 409 Conflict is more accurate (and 400 is acceptable). The 404 leaks the existence of the worker name.


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

Comment thread tinyagentos/routes/cluster.py Outdated
return JSONResponse(drain_result, status_code=404)

# Step 2: Trigger the update-worker deploy command on the worker
import httpx

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: Inline import of httpx

import httpx is inside the handler body. Move it to the module-level imports at the top of the file for consistency with the rest of the codebase and to avoid the import cost on every call.


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

@kilo-code-bot

kilo-code-bot Bot commented Jul 7, 2026

Copy link
Copy Markdown

Code Review Summary

Status: 6 Issues Found | Recommendation: Address before merge

Overview

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

CRITICAL

File Line Issue
tinyagentos/routes/cluster.py 1139 Destructive endpoints (/drain, /cancel-drain, /update) have no authentication / authorization / rate limiting

WARNING

File Line Issue
tinyagentos/routes/cluster.py 1191 httpx.ConnectError misclassified as expected mid-restart disconnect — an unreachable worker gets a 200 and stays draining without the update ever being delivered; restart-induced ReadTimeout also falls through and wrongly cancels the drain (NEW)
tinyagentos/cluster/manager.py 435 Lease mutations in drain_worker force path bypass _lease_lock (serialized per class docstring)
tinyagentos/cluster/manager.py 446 Fire-and-forget notification create_task result dropped instead of retained in self._background_tasks

SUGGESTION

File Line Issue
tinyagentos/routes/cluster.py 1135 cancel_drain returns 404 for a state-conflict error; should be 409 (or 400) and 404 leaks worker existence
tinyagentos/routes/cluster.py 1188 f-string with no placeholders — drop the f prefix (NEW)
Resolved in HEAD (incremental commit)
  • tinyagentos/routes/cluster.py — HTTP response status now checked via resp.raise_for_status() before resp.json()
  • tinyagentos/routes/cluster.py — broad except Exception split into HTTPStatusError (cancel), disconnect (keep drain), and unexpected (cancel) branches
  • tinyagentos/routes/cluster.pyimport httpx / import re moved to module-level imports
  • tinyagentos/routes/cluster.py — re-entry on draining status now returns 409 (idempotency guard added)
  • tinyagentos/cluster/manager.py — monitor force-finish drain path now pops leases under _lease_lock; local worker skipped in monitor loop
Files Reviewed (3 files)
  • tinyagentos/routes/cluster.py - 4 issues (1 new)
  • tinyagentos/cluster/manager.py - 2 issues
  • tests/test_routes_cluster.py - 0 issues

Fix these issues in Kilo Cloud

Previous Review Summaries (3 snapshots, latest commit a46b09b)

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

Previous review (commit a46b09b)

Status: 6 Issues Found | Recommendation: Address before merge

Overview

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

CRITICAL

File Line Issue
tinyagentos/routes/cluster.py 1139 Destructive endpoints (/drain, /cancel-drain, /update) have no authentication / authorization / rate limiting

WARNING

File Line Issue
tinyagentos/routes/cluster.py 1191 httpx.ConnectError misclassified as expected mid-restart disconnect — an unreachable worker gets a 200 and stays draining without the update ever being delivered; restart-induced ReadTimeout also falls through and wrongly cancels the drain (NEW)
tinyagentos/cluster/manager.py 435 Lease mutations in drain_worker force path bypass _lease_lock (serialized per class docstring)
tinyagentos/cluster/manager.py 446 Fire-and-forget notification create_task result dropped instead of retained in self._background_tasks

SUGGESTION

File Line Issue
tinyagentos/routes/cluster.py 1135 cancel_drain returns 404 for a state-conflict error; should be 409 (or 400) and 404 leaks worker existence
tinyagentos/routes/cluster.py 1188 f-string with no placeholders — drop the f prefix (NEW)
Resolved in HEAD (incremental commit)
  • tinyagentos/routes/cluster.py — HTTP response status now checked via resp.raise_for_status() before resp.json()
  • tinyagentos/routes/cluster.py — broad except Exception split into HTTPStatusError (cancel), disconnect (keep drain), and unexpected (cancel) branches
  • tinyagentos/routes/cluster.pyimport httpx / import re moved to module-level imports
  • tinyagentos/routes/cluster.py — re-entry on draining status now returns 409 (idempotency guard added)
  • tinyagentos/cluster/manager.py — monitor force-finish drain path now pops leases under _lease_lock; local worker skipped in monitor loop
Files Reviewed (3 files)
  • tinyagentos/routes/cluster.py - 4 issues (1 new)
  • tinyagentos/cluster/manager.py - 2 issues
  • tests/test_routes_cluster.py - 0 issues

Fix these issues in Kilo Cloud

Previous review (commit 6903c00)

Status: 8 Issues Found | Recommendation: Address before merge

Overview

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

CRITICAL

File Line Issue
tinyagentos/routes/cluster.py 1141 Destructive endpoints (/drain, /cancel-drain, /update) have no authentication / authorization / rate limiting
tinyagentos/routes/cluster.py 1176 resp.json() called without checking resp.status_code; worker returning 5xx HTML triggers the failure path and cancels the drain on a worker that actually accepted update-worker

WARNING

File Line Issue
tinyagentos/cluster/manager.py 435 Lease mutations bypass _lease_lock even though class docstring (lines 35-38) declares them serialized
tinyagentos/cluster/manager.py 446 Notification create_task result dropped on the floor instead of being added to self._background_tasks
tinyagentos/routes/cluster.py 1156 No idempotency / re-entry guard; concurrent or retried /update calls silently trigger a second update-worker deploy
tinyagentos/routes/cluster.py 1177 Broad except Exception unconditionally cancels the drain even when the drain was initiated independently by another caller

SUGGESTION

File Line Issue
tinyagentos/routes/cluster.py 1137 cancel_drain returns 404 for a state-conflict error; should be 409 (or 400) and the 404 leaks worker existence
tinyagentos/routes/cluster.py 1168 import httpx inside handler body — move to module-level imports
Resolved in HEAD (incremental commit)
  • tinyagentos/cluster/manager.py:431 — worker name prefix-collision in drain_worker force path (fixed via _parse_resource_id + parsed[0] == name)
  • tinyagentos/cluster/manager.py:620 — same prefix-collision in monitor drain-completion path (fixed)
  • tinyagentos/cluster/manager.py:639 — same prefix-collision in monitor force-finish path (fixed)
Files Reviewed (3 files)
  • tinyagentos/cluster/manager.py - 2 remaining issues (3 previously flagged prefix-collision issues resolved)
  • tinyagentos/routes/cluster.py - 6 remaining issues
  • tests/test_cluster.py - 0 issues

Fix these issues in Kilo Cloud

Previous review (commit c6d9a98)

Status: 10 Issues Found | Recommendation: Address before merge

Overview

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

CRITICAL

File Line Issue
tinyagentos/cluster/manager.py 431 drain_worker force path uses startswith(name + ":") and reintroduces the gpu-node vs gpu-node-2 collision that the existing monitor-loop code (line 604-610) explicitly guards against
tinyagentos/routes/cluster.py 1141 Destructive endpoints (/drain, /cancel-drain, /update) have no authentication / authorization / rate limiting
tinyagentos/routes/cluster.py 1176 resp.json() called without checking resp.status_code; worker returning 5xx HTML triggers the failure path and cancels the drain on a worker that actually accepted update-worker

WARNING

File Line Issue
tinyagentos/cluster/manager.py 434 Lease mutations bypass _lease_lock even though class docstring (lines 35-38) declares them serialized
tinyagentos/cluster/manager.py 445 Notification create_task result dropped on the floor instead of being added to self._background_tasks
tinyagentos/cluster/manager.py 620 Same startswith(worker.name + ":") prefix-collision bug in the monitor drain-completion path
tinyagentos/routes/cluster.py 1156 No idempotency / re-entry guard; concurrent or retried /update calls silently trigger a second update-worker deploy
tinyagentos/routes/cluster.py 1177 Broad except Exception unconditionally cancels the drain even when the drain was initiated independently by another caller

SUGGESTION

File Line Issue
tinyagentos/routes/cluster.py 1137 cancel_drain returns 404 for a state-conflict error; should be 409 (or 400) and the 404 leaks worker existence
tinyagentos/routes/cluster.py 1168 import httpx inside handler body — move to module-level imports
Files Reviewed (3 files)
  • tinyagentos/cluster/manager.py - 4 issues
  • tinyagentos/routes/cluster.py - 6 issues
  • tests/test_cluster.py - 0 issues

Fix these issues in Kilo Cloud


Reviewed by hy3-20260706:free · Input: 78.5K · Output: 11.7K · Cached: 185.1K

Replace startswith(name + ':') with _parse_resource_id + exact
parsed[0] == name compare in three drain code paths to prevent
accidental cross-worker lease release when worker names share
a prefix (e.g. 'worker-a' matching 'worker-a-pro' leases).

Fixes jaylfc#1690

@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: 2

🧹 Nitpick comments (2)
tests/test_cluster.py (1)

363-379: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Add a heartbeat regression for draining workers.

The suite verifies lease claims fail after drain_worker(), but it does not cover the normal heartbeat path that can flip a draining worker back to "online".

🧪 Proposed test
+    async def test_heartbeat_does_not_cancel_drain(self):
+        """Heartbeats should refresh metadata without making a draining worker routable."""
+        mgr = ClusterManager()
+        await mgr.register_worker(_make_worker("draining-gpu", capabilities=["chat"]))
+
+        mgr.drain_worker("draining-gpu", graceful=True)
+        assert mgr.heartbeat("draining-gpu", load=0.2) is True
+
+        assert mgr.get_worker("draining-gpu").status == "draining"
+        assert mgr.get_workers_for_capability("chat") == []
🤖 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_cluster.py` around lines 363 - 379, Add a regression test covering
the heartbeat path for draining workers, since
`test_draining_workers_excluded_from_lease_claim` only checks `claim_lease()`
after `drain_worker()`. Extend the `ClusterManager`/worker heartbeat flow to
verify that a worker marked draining does not get flipped back to "online" by a
normal heartbeat, and then assert `claim_lease()` still returns `None` for that
worker after the heartbeat. Use the existing `ClusterManager`, `drain_worker()`,
and heartbeat handling logic to place the test near the current draining-worker
lease test.
tinyagentos/routes/cluster.py (1)

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

Avoid constructing DrainRequest in the function default.

Move the default construction into the handler so the endpoint satisfies Ruff B008 and avoids shared default-object surprises.

Proposed fix
-async def drain_worker(request: Request, name: str, body: DrainRequest = DrainRequest()):
+async def drain_worker(request: Request, name: str, body: DrainRequest | None = None):
@@
     """
+    body = body or DrainRequest()
     cluster = request.app.state.cluster_manager
🤖 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/routes/cluster.py` at line 1114, The drain_worker handler is
constructing a DrainRequest as a function default, which triggers Ruff B008 and
can create a shared mutable default. Update drain_worker so the body parameter
defaults to None, then instantiate DrainRequest inside the handler when no body
is provided; keep the change localized to the drain_worker endpoint and preserve
the existing request handling behavior.

Source: Linters/SAST tools

🤖 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/cluster/manager.py`:
- Around line 400-421: The `drain_worker()` flow in `ClusterManager` sets a
worker to `"draining"`, but `heartbeat()` later overwrites that status back to
`"online"` unconditionally. Update `heartbeat()` to preserve `"draining"`
workers as draining (and avoid re-enabling routing) until the drain completes or
the worker is explicitly taken offline, using the existing
`drain_worker()`/`heartbeat()` status handling logic.

In `@tinyagentos/routes/cluster.py`:
- Around line 1162-1176: The restart flow in cluster.py is triggering the
update-worker deploy too early after drain_worker(..., graceful=True), which
only starts draining and does not guarantee leases are finished. Update the
logic around the drain_result and the httpx.AsyncClient POST to
/api/worker/deploy so the deploy only happens after the worker is confirmed
lease-free/offline, or split it into a drain-start response plus a
background/monitor step that sends the deploy once drain completion is reached.
Use the existing drain_worker and deploy_result flow to locate and restructure
this sequence.

---

Nitpick comments:
In `@tests/test_cluster.py`:
- Around line 363-379: Add a regression test covering the heartbeat path for
draining workers, since `test_draining_workers_excluded_from_lease_claim` only
checks `claim_lease()` after `drain_worker()`. Extend the
`ClusterManager`/worker heartbeat flow to verify that a worker marked draining
does not get flipped back to "online" by a normal heartbeat, and then assert
`claim_lease()` still returns `None` for that worker after the heartbeat. Use
the existing `ClusterManager`, `drain_worker()`, and heartbeat handling logic to
place the test near the current draining-worker lease test.

In `@tinyagentos/routes/cluster.py`:
- Line 1114: The drain_worker handler is constructing a DrainRequest as a
function default, which triggers Ruff B008 and can create a shared mutable
default. Update drain_worker so the body parameter defaults to None, then
instantiate DrainRequest inside the handler when no body is provided; keep the
change localized to the drain_worker endpoint and preserve the existing request
handling behavior.
🪄 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: a3cfade6-53b5-44c7-9806-8c0d64329b54

📥 Commits

Reviewing files that changed from the base of the PR and between 2523191 and 6903c00.

📒 Files selected for processing (3)
  • tests/test_cluster.py
  • tinyagentos/cluster/manager.py
  • tinyagentos/routes/cluster.py

Comment thread tinyagentos/cluster/manager.py Outdated
Comment on lines +400 to +421
def drain_worker(self, name: str, graceful: bool = True) -> dict:
"""Begin draining a worker — gracefully detach without dropping tasks.

When ``graceful=True`` (the default):
1. Worker enters ``"draining"`` status — no new tasks routed to it.
2. Existing leases are allowed to run to completion (TTL not touched).
3. The monitor loop will eventually sweep expired leases and then
mark the worker ``"offline"`` once all leases are released.

When ``graceful=False``:
1. Worker enters ``"draining"`` status.
2. All active leases for this worker are released immediately.
3. Worker is marked ``"offline"`` immediately.

Returns a dict with ``worker``, ``previous_status``, ``released_leases``,
and ``status``.
"""
worker = self._workers.get(name)
if worker is None:
return {"worker": name, "error": "worker not found"}
prev_status = worker.status
worker.status = "draining"

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

Preserve draining across heartbeats.

drain_worker() only changes worker.status, but heartbeat() unconditionally sets that same worker back to "online" on the next heartbeat, so a draining worker can re-enter routing before leases finish.

🐛 Proposed fix
-        worker.status = "online"
+        if worker.status != "draining":
+            worker.status = "online"
🤖 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 400 - 421, The `drain_worker()`
flow in `ClusterManager` sets a worker to `"draining"`, but `heartbeat()` later
overwrites that status back to `"online"` unconditionally. Update `heartbeat()`
to preserve `"draining"` workers as draining (and avoid re-enabling routing)
until the drain completes or the worker is explicitly taken offline, using the
existing `drain_worker()`/`heartbeat()` status handling logic.

Comment on lines +1162 to +1176
# Step 1: Begin draining
drain_result = cluster.drain_worker(name, graceful=True)
if "error" in drain_result:
return JSONResponse(drain_result, status_code=404)

# Step 2: Trigger the update-worker deploy command on the worker
import httpx
deploy_result = None
try:
async with httpx.AsyncClient(timeout=620) as client:
resp = await client.post(
f"{worker.url}/api/worker/deploy",
json={"command": "update-worker"},
)
deploy_result = resp.json()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Wait for graceful drain completion before triggering the restart.

drain_worker(..., graceful=True) only marks the worker as draining; existing leases remain active. Posting update-worker immediately can restart the worker before inflight work finishes, contradicting the documented sequence in Lines 1145-1149. Gate the deploy on the worker becoming lease-free/offline, or split this into a drain-start response plus a monitor/background step that sends the deploy after drain completion.

🤖 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/routes/cluster.py` around lines 1162 - 1176, The restart flow in
cluster.py is triggering the update-worker deploy too early after
drain_worker(..., graceful=True), which only starts draining and does not
guarantee leases are finished. Update the logic around the drain_result and the
httpx.AsyncClient POST to /api/worker/deploy so the deploy only happens after
the worker is confirmed lease-free/offline, or split it into a drain-start
response plus a background/monitor step that sends the deploy once drain
completion is reached. Use the existing drain_worker and deploy_result flow to
locate and restructure this sequence.

hognek added a commit to hognek/tinyagentos that referenced this pull request Jul 7, 2026
…on force-release, wire arbiter task cancellation

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

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

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

jaylfc commented Jul 9, 2026

Copy link
Copy Markdown
Owner

Deep-reviewed this one, @hognek — the drain/pause/routing/catalog/lease-exclusion design is genuinely good and the unit tests for it are solid. Two blocking items before I merge, plus a couple worth folding:

BLOCKING 1 — the local worker guard was removed (regression, not caught by any bot). This PR deletes if worker.name == "local": continue from _monitor_loop in manager.py (it was ~line 513 on dev). The local worker is the controller itself, kept alive only by local_heartbeat_loop (15s interval vs HEARTBEAT_TIMEOUT = 30s). With the guard gone, any ~30s stall of that heartbeat task marks the controller itself offline → drops its leases → drops it from routing + aggregate_catalog, so local backends vanish from the cluster. The comment you removed warned about exactly this. It is unrelated to the auto-update feature — please restore the guard.

BLOCKING 2 — the update flow reports failure on success and cancels a drain the worker accepted (Kilo CRITICAL). In update_worker (routes/cluster.py): (a) deploy_result = resp.json() never checks resp.status_code; (b) the except Exception unconditionally calls cancel_drain(name) and returns 502. But the worker's /api/worker/deploy blocks until the update script finishes and update-worker restarts the worker service — so the happy path drops the HTTP connection mid-request → caught as Exception → drain cancelled → 502 returned even though the update succeeded (it self-heals via re-register, but the user sees a false failure and there is a window where a cancelled-to-online worker is mid-restart and gets routed to). Please check resp.status_code/raise_for_status() and make the deploy call restart-aware (fire-and-forget or a drain-stays-until-reregister contract) instead of cancelling on the expected disconnect.

Worth folding (non-blocking):

  • Move the force-path lease pops (drain_worker(graceful=False) and the monitor stale-drain branch) under _lease_lock for consistency with the offline path. I checked — it is not an active race today (synchronous def, no await between read and mutation, so atomic under asyncio), but it contradicts the class docstring's serialization claim.
  • The update_worker route has no tests — the httpx deploy, the failure/rollback path, and the restart contract (item 2) are all uncovered; the monitor auto-complete + stale-drain-timeout branches too. At least one test around the update failure path would be good given it is the riskiest addition.
  • Minor: no idempotency guard on /update (a retry fires a second deploy); notification create_task(...) results are dropped.

Once 1 + 2 are in (and ideally an update_worker test), I will merge. Also heads-up: this and #1726 both touch the unregister/monitor region of manager.py + test_cluster.py, so whichever lands second needs a trivial rebase. Strong feature overall — just needs the local-guard restored and the update-flow contract fixed.

…#1690

- Restore local worker guard in _monitor_loop (prevents controller
  from marking itself offline and dropping its own leases).
- Fix update_worker error handling: use raise_for_status() for real
  HTTP errors; catch expected disconnect during worker restart
  without cancelling drain; keep drain active until re-registration.
- Move force-path lease pops under _lease_lock for consistency with
  the online->offline path.
- Add idempotency guard: /update returns 409 if worker is already
  draining.
- Move httpx import to module level in routes/cluster.py (matches
  codebase convention).
- Add 7 tests for update_worker covering all error paths.
@hognek

hognek commented Jul 10, 2026

Copy link
Copy Markdown
Contributor Author

Addressed all review blockers from jaylfc's deep review:

BLOCKING 1 — Restored if worker.name == "local": continue in _monitor_loop. The PR had deleted this guard (unrelated regression), which would cause the controller to mark itself offline and drop its own leases.

BLOCKING 2 — Fixed update_worker error handling:

  • Added raise_for_status() for real HTTP errors
  • Catch expected disconnect during worker restart (ConnectError / RemoteProtocolError / ReadError) — drain stays active, worker re-registers naturally
  • Only cancel drain on unexpected errors

Non-blocking:

  • Moved force-path lease pops under _lease_lock
  • Added idempotency guard: 409 if worker is already draining
  • 7 new tests covering all update_worker paths
  • Moved import httpx to module level (matches codebase convention)

Tests: 7/7 new tests pass; all cluster manager + routes_cluster tests pass (44/44).

Comment thread tinyagentos/routes/cluster.py Outdated
{"error": f"Worker update deploy rejected by worker", "drain_cancelled": True},
status_code=502,
)
except (httpx.ConnectError, httpx.RemoteProtocolError, httpx.ReadError):

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: httpx.ConnectError is misclassified as an expected mid-restart disconnect.

ConnectError is raised when the TCP connection can never be established (connection refused, DNS failure, worker down/unreachable) — meaning the update-worker deploy request was never delivered. Treating it as "restart expected" makes the endpoint return 200 {status: acknowledged} and keep the worker draining even though the update never started. The worker then stays out of rotation until the monitor's HEARTBEAT_TIMEOUT force-finishes the drain, silently removing a healthy/unreachable worker from capacity without ever updating it.

A true mid-restart disconnect surfaces as RemoteProtocolError/ReadError (connection already established, server drops it). Consider dropping ConnectError from this branch, or only treating it as "restart expected" after the request is known to have been sent.

Additionally, a restart that stalls will raise httpx.ReadTimeout/TimeoutException (not in this tuple), falling through to the generic except Exception at line 1202 and wrongly cancelling the drain. Timeouts during a restart should arguably be handled here too.


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

Comment thread tinyagentos/routes/cluster.py Outdated
cluster.cancel_drain(name)
logger.exception("Worker '%s' update deploy returned HTTP error", name)
return JSONResponse(
{"error": f"Worker update deploy rejected by worker", "drain_cancelled": True},

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: f-string has no placeholders — drop the f prefix.

Suggested change
{"error": f"Worker update deploy rejected by worker", "drain_cancelled": True},
{"error": "Worker update deploy rejected by worker", "drain_cancelled": True},

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

@jaylfc

jaylfc commented Jul 10, 2026

Copy link
Copy Markdown
Owner

Thanks Hognek, the blocker fixes look right: restoring the local guard in _monitor_loop and splitting the update_worker error handling are exactly what was needed, and the idempotency 409 plus 7 new tests are good additions.

Kilo flagged a few things on the new revision worth folding before this merges, one of them important:

CRITICAL (routes/cluster.py ~1139): the destructive endpoints (/drain, /cancel-drain, /update) have no auth/authorization. They can drain or update workers, so they need the same admin/session gate the other mutating cluster routes use (and ideally rate limiting). This is the real blocker.

WARNING (routes/cluster.py ~1191): the disconnect classification is not quite right yet. httpx.ConnectError is being treated as an expected mid-restart disconnect, so an unreachable worker returns 200 and stays draining with the update never delivered; and a restart-induced ReadTimeout falls through and wrongly cancels the drain. Worth distinguishing connection-refused / DNS (worker genuinely down, do not silently keep draining) from a connection dropped after the request was accepted (the real restart case).

WARNING (cluster/manager.py ~435): there is a second force path in drain_worker (separate from the monitor path you fixed) whose lease pops still bypass _lease_lock. Move those under the lock too.

WARNING (cluster/manager.py ~446): the fire-and-forget notification create_task result is dropped; retain it in self._background_tasks with an add_done_callback(discard) so it is not GC-collected mid-flight.

Minor: cancel_drain returning 404 on a state conflict should be 409 (404 also leaks worker existence), and one f-string at ~1188 has no placeholders.

Once the auth gate plus the two manager.py WARNINGs land I will re-review. This is a standalone worker-auto-update change (it does not touch the VRAM arbiter code), so it can go in on its own once these clear. Nice progress on the blockers.

…lfc#1690

CRITICAL: Add _require_admin auth gate to /drain, /cancel-drain, /update
endpoints — all three were missing the admin-session check that every
other mutating cluster route uses.

WARNING — disconnect classification: Remove ConnectError from the
expected-restart tuple (connection-refused means unreachable, not
restarting) and add ReadTimeout (the real mid-restart signal).
ConnectError now cancels the drain and returns 502.

WARNING — lease-lock: Make drain_worker async and wrap the
force-release lease pops in async with self._lease_lock so
concurrent claim/release operations see a consistent view.

WARNING — task retention: Capture notification create_task results
in drain_worker and cancel_drain, retain in self._background_tasks
with add_done_callback(discard).

Minor — cancel_drain returns 409 (not 404) on state conflict.
Minor — bare f-string fix in update_worker error message.

Tests: 22/22 drain+update tests pass (9 unit + 13 HTTP), including
6 new tests (3 auth-gate, ConnectError-as-failure, ReadTimeout-as-success,
cancel_drain 409).

@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: 1

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

691-724: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Assert the exact authorization response.

Accepting 401 allows a middleware response to pass even if the endpoint-level admin guard regresses. _require_admin defines a 403 response; assert that status and the forbidden body.

Proposed assertions
-    assert resp.status_code in (401, 403), f"got {resp.status_code}: {resp.text}"
+    assert resp.status_code == 403
+    assert resp.json() == {"error": "forbidden"}

Apply this to all three authorization tests.

🤖 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_routes_cluster.py` around lines 691 - 724, Update
test_drain_requires_admin, test_cancel_drain_requires_admin, and
test_update_worker_requires_admin to assert exactly HTTP 403 and verify the
response body contains the expected "forbidden" value, replacing the current
(401, 403) status checks.

573-591: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Verify the outgoing deployment contract.

The mock succeeds regardless of URL or payload, so a regression sending the wrong command would pass.

Proposed assertion
     with patch("tinyagentos.routes.cluster.httpx.AsyncClient", return_value=mock_client):
         resp = await client.post("/api/cluster/workers/gpu-box/update")

+    mock_client.post.assert_awaited_once_with(
+        "http://gpu:9000/api/worker/deploy",
+        json={"command": "update-worker"},
+    )
🤖 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_routes_cluster.py` around lines 573 - 591, Strengthen the test for
the worker update route by asserting the mocked httpx.AsyncClient.post call in
the test setup around the request to `/api/cluster/workers/gpu-box/update`.
Verify the outgoing deployment URL and payload include the expected worker
identifier and `"update-worker"` command, while retaining the existing response
assertions.
🤖 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/routes/cluster.py`:
- Around line 1139-1141: Update the cancel-drain flow around the
cluster.cancel_drain call and the worker-draining logic near the referenced
update handling to track a distinct active-update state or token; while that
state is active, reject ordinary cancellation with an appropriate conflict
response instead of reopening routing, and clear the state only when the update
completes or is safely aborted.

---

Nitpick comments:
In `@tests/test_routes_cluster.py`:
- Around line 691-724: Update test_drain_requires_admin,
test_cancel_drain_requires_admin, and test_update_worker_requires_admin to
assert exactly HTTP 403 and verify the response body contains the expected
"forbidden" value, replacing the current (401, 403) status checks.
- Around line 573-591: Strengthen the test for the worker update route by
asserting the mocked httpx.AsyncClient.post call in the test setup around the
request to `/api/cluster/workers/gpu-box/update`. Verify the outgoing deployment
URL and payload include the expected worker identifier and `"update-worker"`
command, while retaining the existing response assertions.
🪄 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: fdca2baa-579f-4f29-932e-ec4367206e64

📥 Commits

Reviewing files that changed from the base of the PR and between 6903c00 and ad9d073.

📒 Files selected for processing (4)
  • tests/test_cluster.py
  • tests/test_routes_cluster.py
  • tinyagentos/cluster/manager.py
  • tinyagentos/routes/cluster.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • tests/test_cluster.py
  • tinyagentos/cluster/manager.py

Comment on lines +1139 to +1141
result = cluster.cancel_drain(name)
if "error" in result:
return JSONResponse(result, status_code=409)

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 | 🏗️ Heavy lift

Prevent cancel-drain from reopening routing during an active update.

After Line 1175 marks the worker as draining, the deployment request can remain pending for up to 620 seconds. During that window, /cancel-drain can restore it to online, allowing new leases immediately before the worker restarts. Track a distinct update state/token and reject ordinary cancellation while it is active.

Also applies to: 1174-1186

🤖 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/routes/cluster.py` around lines 1139 - 1141, Update the
cancel-drain flow around the cluster.cancel_drain call and the worker-draining
logic near the referenced update handling to track a distinct active-update
state or token; while that state is active, reject ordinary cancellation with an
appropriate conflict response instead of reopening routing, and clear the state
only when the update completes or is safely aborted.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants