Skip to content

feat(cluster): worker-initiated graceful pause + drain protocol (taOS #890 C2)#1903

Merged
jaylfc merged 5 commits into
jaylfc:devfrom
hognek:feat/worker-self-drain
Jul 18, 2026
Merged

feat(cluster): worker-initiated graceful pause + drain protocol (taOS #890 C2)#1903
jaylfc merged 5 commits into
jaylfc:devfrom
hognek:feat/worker-self-drain

Conversation

@hognek

@hognek hognek commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Task: t_33babf4b. Fixes #890 (partial — C2 drain protocol).

What

Implements worker-initiated drain: the worker can self-drain via heartbeat
instead of waiting for the controller to push a drain command.

Changes

Controller side (cluster/manager.py)

  • heartbeat() accepts optional status and drain_reason fields
  • Workers can report "update-available", "draining", or "updating"
  • _worker_for_resource, get_workers_for_capability, aggregate_catalog include "update-available" alongside "online"
  • Monitor loop handles stale update-available workers
  • Worker-initiated drain emits notifications to activity feed

Route side (routes/cluster.py)

  • HeartbeatBody extended with status and drain_reason fields
  • Route handler passes them through to cluster.heartbeat()

Worker side (worker/agent.py)

  • New convenience methods: report_update_available(), initiate_self_drain(), notify_drain_complete()
  • heartbeat() accepts optional status and drain_reason parameters

State machine

online → update-available → draining (self-initiated) → updating → restarting → online
                                                                       ↓ (fail)
                                                                   rollback → online

Tests

11 new unit tests covering:

  • Self-drain via heartbeat
  • Update-available status + routability
  • Catalog inclusion for update-available workers
  • Lease claims on update-available workers
  • Full state transition pipeline
  • Drain preservation on subsequent heartbeats
  • Update-available re-online on normal heartbeat
  • Monitor loop staleness for update-available
  • Updating status routing exclusion

Test results

tests/test_cluster.py::TestWorkerDrain — 38/38 passed (0.42s)
tests/test_cluster_worker_protocol.py — 27/27 passed
tests/test_cluster_optimiser.py (unit only) — 17/17 passed
``"

Depends on: C0 (design doc #1896), C1 (version detection, triggers the drain)
Next task: C3 (self-install + restart + re-register + rollback)

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- **New Features**
  - Workers can now report lifecycle states (update available, graceful draining, and draining complete) along with an optional drain reason.
  - “Update available” workers remain eligible for resource routing and can claim leases.
  - “Updating” workers are excluded from new routing and are handled as a distinct lifecycle stage.

- **Bug Fixes**
  - Heartbeat-driven state changes now preserve protected worker states (including draining/update availability) across subsequent heartbeats.
  - If heartbeats go stale, update-available/updating workers are marked offline and any held leases are released; misleading “back online” notifications are reduced.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->


## CodeRabbit fixes (t_b5d624e9)

**Fix 1 (MAJOR):** Persist lifecycle status across controller restart.
Worker stores _lifecycle_status/_lifecycle_reason and resends on periodic heartbeats. If controller restarts, re-registered workers retain their drain/update state instead of silently reverting to online.

**Fix 2 (MAJOR):** Cancel GPU arbiter tasks on stale-update-available cleanup.
The timeout path for update-available workers now cancels arbiter tasks, matching stale-drain and stale-update paths.

**Fix 3 (MINOR):** Gate recovery online event on worker.status == online.
The worker.online notification only fires when genuinely online, not when recovering into draining/updating state.

Tests: 77/77 targeted (test_cluster.py + test_worker.py) pass.

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

@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Changes

Worker heartbeats now carry optional drain and update statuses through the cluster API. ClusterManager preserves worker-reported states, routes work to update-available workers, excludes updating workers, and handles stale-worker cleanup. WorkerAgent adds helpers for update availability, self-draining, and drain completion.

Worker state lifecycle

Layer / File(s) Summary
Heartbeat status contract
tinyagentos/worker/agent.py, tinyagentos/routes/cluster.py, tinyagentos/cluster/manager.py, tests/test_worker.py
Heartbeat payloads accept status and drain_reason; worker lifecycle state persists across re-registration, and worker tests accept the expanded heartbeat call.
State transitions and routing visibility
tinyagentos/cluster/manager.py, tests/test_cluster.py
Protected statuses persist across status-less heartbeats, notifications are emitted, and update-available workers remain routable, catalog-visible, and lease-eligible while updating workers are excluded.
Timeout handling and update cleanup
tinyagentos/cluster/manager.py, tests/test_cluster.py
Stale update-available and updating workers become offline; leases are released and GPU arbiter tasks are cancelled.

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

Sequence Diagram(s)

sequenceDiagram
  participant WorkerAgent
  participant ClusterAPI
  participant ClusterManager
  participant Scheduler
  WorkerAgent->>ClusterAPI: heartbeat(status, drain_reason)
  ClusterAPI->>ClusterManager: forward heartbeat state
  ClusterManager->>ClusterManager: update worker status
  Scheduler->>ClusterManager: request eligible worker
  ClusterManager-->>Scheduler: return online or update-available worker
Loading

Possibly related PRs

  • jaylfc/taOS#1859: Modifies heartbeat-driven draining behavior and related routing interactions.
  • jaylfc/taOS#1872: Modifies monitor-loop handling for worker state transitions and disconnects.
  • jaylfc/taOS#1896: Documents the worker update and drain protocol implemented by this change.

Suggested reviewers: jaylfc

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The PR covers the C2 drain-protocol portion of #890 with status handling, routing, heartbeat updates, and tests.
Out of Scope Changes check ✅ Passed The patch stays focused on worker lifecycle, heartbeat, routing, and test updates without unrelated feature additions.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: a worker-initiated graceful pause/drain protocol for cluster workers.
✨ 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 17, 2026

Copy link
Copy Markdown

Gitar is working

Gitar

# Handle online workers that haven't heartbeated
if worker.status == "online" and (now - worker.last_heartbeat) > HEARTBEAT_TIMEOUT:
# Handle online / update-available workers that haven't heartbeated
if worker.status in ("online", "update-available") and (now - worker.last_heartbeat) > HEARTBEAT_TIMEOUT:

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: The updating status is never handled in _monitor_loop. A worker that transitions to updating and then stops heartbeating (crash, network loss during update, restart) will stay stuck in updating forever — it is permanently excluded from routing and the catalog, and is never re-onlined or marked offline. Only online/update-available are auto-offlined here, and draining has its own branch. Either add updating to this staleness check or give updating an explicit terminal/timeout transition, otherwise a single failed update permanently removes a worker from the mesh.


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

Comment thread tinyagentos/cluster/manager.py Outdated
# Worker-initiated drain notification (taOS #890 C2).
# Emit when the worker transitions into draining/update-available on
# its own initiative, so the operator sees it in the activity feed.
if self._notifications and status in ("draining", "update-available", "updating") and prev_status not in (status,):

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: drain_reason (and the raw status string) arrive unvalidated from the HTTP body (routes/cluster.py HeartbeatBody). They are directly interpolated into notification titles/details (lines 283-285) and into the event_type/fallback title via f-strings. Since these are operator-facing strings in an activity feed, consider validating status against the allowed set and clamping/escaping drain_reason (e.g. length cap, strip control chars) before interpolation to avoid log/feed injection and odd event types like worker.somecontrolchar.


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

Comment thread tinyagentos/worker/agent.py Outdated
Returns the HTTP status code from the controller.
"""
logger.info("worker '%s': drain complete, ready for update", self.name)
return await self.heartbeat()

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: notify_drain_complete() just sends a plain heartbeat() with no status. Per manager.py:226-231, a plain heartbeat from a draining worker keeps it draining (sticky), so this method does not actually signal completion or move the worker to updating. The docstring claims it lets the controller "proceed with the update deploy," but the controller has no handler for this. Either send status="updating" here (so it transitions and gets excluded from routing per aggregate_catalog line 662), or remove the misleading docstring/behavior.


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

@kilo-code-bot

kilo-code-bot Bot commented Jul 17, 2026

Copy link
Copy Markdown

Code Review Summary

Status: No Issues Found | Recommendation: Merge

Files Reviewed (1 file in incremental diff)
  • tinyagentos/cluster/manager.py - incremental change only
Previous Findings — Resolved in current HEAD (187ca2d)
  • WARNING (manager.py:248) Invalid status value reverting a protected worker to "online" — FIXED. Heartbeat guard now uses _VALID_STATUSES and only reverts to online for non-protected workers or genuinely invalid status, preserving draining/update-available/updating against bad status values. ✅
  • SUGGESTION (manager.py:239) Duplicated allow-list literals — FIXED. Hoisted to module-level _VALID_STATUSES frozenset used by both the heartbeat guard and the notification block. ✅
Previous Review Summaries (5 snapshots, latest commit 187ca2d)

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

Previous review (commit 187ca2d)

Status: No Issues Found | Recommendation: Merge

Files Reviewed (1 file in incremental diff)
  • tinyagentos/cluster/manager.py - incremental change only
Previous Findings — Resolved in current HEAD (187ca2d)
  • WARNING (manager.py:248) Invalid status value reverting a protected worker to "online" — FIXED. Heartbeat guard now uses _VALID_STATUSES and only reverts to online for non-protected workers or genuinely invalid status, preserving draining/update-available/updating against bad status values. ✅
  • SUGGESTION (manager.py:239) Duplicated allow-list literals — FIXED. Hoisted to module-level _VALID_STATUSES frozenset used by both the heartbeat guard and the notification block. ✅

Previous review (commit 8dee96f)

Status: 2 Issues Found | Recommendation: Address before merge

Overview

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

WARNING

File Line Issue
tinyagentos/cluster/manager.py 243 Invalid status value reverts a protected (draining/update-available/updating) worker to "online", defeating the #890 drain protection. The guard at line 239 only blocks status-less heartbeats; any non-None, non-valid status hits the else branch and forces worker.status = "online", re-entering a draining worker into routing/catalog/lease-claims mid-drain.

SUGGESTION

File Line Issue
tinyagentos/cluster/manager.py 239 The worker-reported status allow-list is duplicated as inline literals in three places (line 239, 241, 314). Hoist a single module/class-level constant to keep them in lockstep.
Previously Reported Findings — Resolved in current HEAD (8dee96f)
  • WARNING (manager.py:760) updating now handled in _monitor_loop stale branch — force-offlined after HEARTBEAT_TIMEOUT. ✅
  • WARNING (manager.py:239) "updating" now protected from status-less heartbeat reversion (added to the protected tuple). ✅
  • SUGGESTION (manager.py:835) stale-updating branch force-releases leases and cancels running GPU-arbiter tasks, mirroring stale-draining. ✅
  • SUGGESTION (manager.py) drain_reason is sanitized and status validated against an allow-list before use in notifications. ✅
  • SUGGESTION (worker/agent.py) notify_drain_complete() now sends status="updating" so the controller transitions drainingupdating. ✅
Files Reviewed (4 files in PR diff)
  • tinyagentos/cluster/manager.py - 2 new issues + 5 prior resolved
  • tinyagentos/routes/cluster.py - no new issues
  • tinyagentos/worker/agent.py - no new issues
  • tests/test_cluster.py - no new issues

Fix these issues in Kilo Cloud

Previous review (commit 6f28a26)

Status: No Issues Found | Recommendation: Merge

All previously reported findings have been resolved in commit 6f28a266:

  • WARNING (tinyagentos/cluster/manager.py:239): "updating" is now protected from status-less heartbeat reversion — added to the protected tuple ("draining", "update-available", "updating"). A live updating worker is no longer flipped back to online by periodic heartbeats. ✅
  • SUGGESTION (tinyagentos/cluster/manager.py:835): The stale-updating branch now mirrors the stale-draining branch by cancelling running GPU-arbiter tasks via self._gpu_arbiter.cancel_running_for_leases(...), preventing orphaned in-flight GPU work. ✅

The incremental diff (1 file, +13/−1) contains no new issues. The arbiter method signature (set[str] -> tuple[int, int]) matches the call site.

Files Reviewed (1 file in incremental diff)
  • tinyagentos/cluster/manager.py - 2 prior findings resolved, 0 new issues

Previous review (commit d47fe0b)

Status: 2 Issues Found | Recommendation: Address before merge

Overview

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

WARNING

File Line Issue
tinyagentos/cluster/manager.py 239 "updating" is not protected from status-less periodic-heartbeat reversion, reintroducing the exact bug this PR fixed for "update-available". A live updating worker gets flipped back to "online" (re-entered into routing/catalog/leases) and the _monitor_loop staleness branch can never fire because heartbeats keep arriving. Add "updating" to the protected tuple.

SUGGESTION

File Line Issue
tinyagentos/cluster/manager.py 835 The stale-updating branch force-releases leases but does not cancel running GPU-arbiter tasks, unlike the stale-draining branch above. Orphaned in-flight GPU work can leak/hang. Mirror the draining branch's arbiter cancellation for parity.
Previously reported issues — now RESOLVED in this iteration
  • CRITICAL (worker.py/manager.py): update-available no longer flipped back to online by status-less heartbeat — now protected at manager.py:239. ✅
  • WARNING (manager.py): updating now handled in _monitor_loop (force-offline + lease release). ✅
  • SUGGESTION (manager.py): status validated against allow-list and drain_reason sanitized. ✅
  • SUGGESTION (worker.py): notify_drain_complete() now sends status="updating". ✅
Files Reviewed (4 files)
  • tinyagentos/cluster/manager.py - 2 issues (1 new WARNING, 1 new SUGGESTION); 2 prior findings resolved
  • tinyagentos/worker/agent.py - prior findings resolved, no new issues
  • tinyagentos/routes/cluster.py - no issues
  • tests/test_cluster.py - no issues

Fix these issues in Kilo Cloud

Previous review (commit 4a4f2be)

Status: 4 Issues Found | Recommendation: Address before merge

Overview

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

CRITICAL

File Line Issue
tinyagentos/worker/agent.py 695 The periodic run-loop calls heartbeat() with no status; combined with manager.py:226-231 this flips any update-available worker straight back to online on its next regular (~15s) heartbeat, silently defeating the worker-initiated update signal. The test bakes this in as expected behavior, but in production update-available can never persist in steady state. (Reported separately; line 695 is outside the diff hunk so the inline comment could not be attached — see this summary note.)

WARNING

File Line Issue
tinyagentos/cluster/manager.py 719 updating status is never handled in _monitor_loop; a worker stuck/stopped in updating is never re-onlined or offlined and stays permanently excluded from routing/catalog.

SUGGESTION

File Line Issue
tinyagentos/cluster/manager.py 274 drain_reason/raw status are unvalidated from the HTTP body and interpolated into activity-feed notifications; validate status against the allow-list and clamp/escape drain_reason.
tinyagentos/worker/agent.py 639 notify_drain_complete() sends a plain heartbeat; from a draining worker this stays draining and does not transition to updating. Send status="updating" or fix the misleading docstring.
Files Reviewed (4 files)
  • tests/test_cluster.py - 0 issues
  • tinyagentos/cluster/manager.py - 2 issues
  • tinyagentos/routes/cluster.py - 0 issues
  • tinyagentos/worker/agent.py - 2 issues

Fix these issues in Kilo Cloud


Reviewed by hy3:free · Input: 43.5K · Output: 3.4K · Cached: 243.6K

@jaylfc

jaylfc commented Jul 17, 2026

Copy link
Copy Markdown
Owner

Coordinates correctly with controller drain state (same draining bucket, no double-drain, monitor loop completes it); 93 tests pass. Rebase needed — cut from stale base, conflicts with manager.py/routes/cluster.py/worker/agent.py from the recently-landed worker-update fixes (#1859/#1872).

Nit (non-blocking): update-available isn't durable — the steady-state ~15s heartbeat sends status=None and manager.py flips it back to online on the next beat. Harmless in the orchestrated flow (report → immediately drain, and draining IS protected), but if the UI shows a persistent 'update available, still serving' badge it'll flicker off. Either protect update-available like draining or document it as transient.

hognek added a commit to hognek/tinyagentos that referenced this pull request Jul 17, 2026
…tor, fast-forward branch (jaylfc#890 C3)

BLOCKER 1 — restart bricks the worker:
- cmd_restart_self: use systemctl restart (single job) instead of
  stop+sleep+start to avoid cgroup kill (KillMode=control-group)
- cmd_rollback: skip _stop_worker_service before checkout and use
  systemctl restart at the end, avoiding same cgroup kill issue
  (worker is already drained at this phase)

BLOCKER 2 — orchestrator is dead code:
- Wire post_update_startup hook into WorkerAgent.run() — runs on
  first registration after restart when update marker exists
- Add _check_update_trigger() — file-based signal (update-trigger.json
  in state_dir) checked on each heartbeat cycle; triggers run_full_update

Fix jaylfc#3:
- pull_update: fast-forward local branch ref after origin/<ref>
  checkout to avoid leaving repo in detached HEAD
- run_health_check: drop unused timeout arg (helper no longer reads it)

Rebase: onto current origin/dev (clean — no conflicts).
Post-jaylfc#1903 rebase pending merge of dependency PR jaylfc#1903 (C2 drain).

Tests: 68/68 pass (test_worker_self_update, test_cluster, test_register_all_routers)
@hognek
hognek force-pushed the feat/worker-self-drain branch from 4a4f2be to d47fe0b Compare July 18, 2026 09:30
Comment thread tinyagentos/cluster/manager.py Outdated
# update signal before the worker can initiate the drain.
# Only block status-less heartbeats; explicit status transitions
# (e.g. "update-available" → "draining") are still honoured.
if worker.status not in ("draining", "update-available") or status is not 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: "updating" is not protected from status-less heartbeat reversion, reintroducing the exact bug this PR fixed for "update-available".

The periodic worker heartbeat calls self.heartbeat() with no status, so status is None. For a worker currently in "updating", worker.status not in ("draining", "update-available") is True, so the branch runs and worker.status = "online". That re-enters the worker into routing/catalog/lease-claims while it is supposedly mid-update, and also means the _monitor_loop staleness branch for updating never fires (heartbeats keep arriving). It looks like "updating" was intended to behave like "draining"/"update-available" (sticky until an explicit transition). Add it to the protected tuple:

Suggested change
if worker.status not in ("draining", "update-available") or status is not None:
if worker.status not in ("draining", "update-available", "updating") or status is not None:

(Then an updating worker only leaves that state via an explicit status transition, e.g. back to "online" after restart/re-register.)

worker.name)
# Handle updating workers: if they stop heartbeating they're
# stuck in a bad state — force-offline them so they can re-onboard.
elif worker.status == "updating":

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: The stale-updating branch force-releases leases but does not cancel running GPU-arbiter tasks, unlike the stale-draining branch just above (lines 823-832). If an updating worker had active leases with in-flight arbiter work when it goes stale, those tasks are orphaned rather than cancelled, while their leases are silently dropped. For parity and to avoid leak/hang of GPU work, mirror the draining branch here:

if lids and self._gpu_arbiter is not None:
    try:
        cancelled, already_done = await self._gpu_arbiter.cancel_running_for_leases(set(lids))
        logger.info(
            "Worker '%s' stale-update: arbiter cancelled %d tasks, %d already done",
            worker.name, cancelled, already_done)
    except Exception:
        logger.exception(
            "gpu-arbiter: cancel for stale-update of '%s' failed",
            worker.name)

(Only relevant once the WARNING on line 239 is fixed so this branch can actually be reached for a live-but-stale updating worker.)

hognek added a commit to hognek/tinyagentos that referenced this pull request Jul 18, 2026
…ncel arbiter tasks on stale-update

Add 'updating' to the protected status tuple in heartbeat so a periodic
status-less heartbeat doesn't revert an updating worker to 'online', which
would re-enter it into routing/catalog/lease-claims mid-update and prevent
the monitor loop's stale-updating branch from firing.

Mirror the stale-draining branch's GPU arbiter task cancellation in the
stale-updating path so that in-flight arbiter work for force-released
leases isn't orphaned when an updating worker goes stale.

Fixes: PR jaylfc#1903 Kilo review findings (WARNING manager.py:239, SUGGESTION manager.py:835)
@hognek

hognek commented Jul 18, 2026

Copy link
Copy Markdown
Contributor Author

Kilo fixes applied (t_450ac573)

Commit: 6f28a26

Fix 1 — manager.py:239 WARNING: Added 'updating' to the protected status tuple ("draining", "update-available", "updating") so periodic status-less heartbeats don't revert an updating worker to "online" mid-update.

Fix 2 — manager.py:857 SUGGESTION: Mirrored the stale-draining branch's GPU arbiter task cancellation in the stale-updating path. Workers stuck in 'updating' with stale heartbeats now cancel in-flight arbiter tasks for force-released leases, same as draining workers.

Tests: 72/72 cluster tests pass (0.53s). Full canonical gate running (pytest tests/ --ignore=tests/e2e -n auto, 9834 items).

@jaylfc

jaylfc commented Jul 18, 2026

Copy link
Copy Markdown
Owner

Deep-reviewed at head 6f28a26: all prior findings verified folded (protected status tuple, stale-updating arbiter-task cancel mirroring the stale-drain branch, drain unified with the controller path) and the update-available durability nit has its regression test. Code is merge-quality. The real CI runs were stuck in action_required from before the fork-policy change - I have approved them; merging as soon as test/lint/spa-build go green. Two non-blocking notes for #1907 while you are in this area: an unrecognized non-None heartbeat status string forces the worker back to online (a typo'd 'drainning' would yank a draining worker into routing - consider rejecting unknown statuses), and #1907 should sequence drain->updating tightly to avoid the transient zero-lease auto-offline race.

hognek added 3 commits July 18, 2026 14:46
…fc#890 C2)

Add worker-initiated status transitions via heartbeat:
- heartbeat() accepts optional status and drain_reason fields
- Workers can self-report 'update-available', 'draining', or 'updating'
- Status 'update-available' workers remain routable (still serving)
- Status 'draining' triggers same behavior as controller-initiated drain
- Monitor loop handles stale update-available workers

Controller side (ClusterManager):
- _worker_for_resource, get_workers_for_capability, aggregate_catalog
  now include 'update-available' alongside 'online'
- Worker-initiated drain emits notifications to activity feed

Worker side (WorkerAgent):
- report_update_available(), initiate_self_drain(), notify_drain_complete()
- Convenience methods wrapping heartbeat() with appropriate status

Tests: 11 new unit tests covering state transitions, routability,
catalog inclusion, lease claims, monitor loop staleness, and the
full online->update-available->draining pipeline.

State machine:
  online -> update-available -> draining -> updating -> (restart) -> online
…worker drain

Rebased feat/worker-self-drain onto origin/dev, resolving conflicts in
manager.py, routes/cluster.py, and worker/agent.py from recently-landed
registration-drift refresh fields (jaylfc#1538).

Kilo fixes:
- CRITICAL: Protect 'update-available' from status-less heartbeat reversion.
  The periodic ~15s heartbeat with no status was silently flipping
  update-available workers back to online, defeating the update signal.
  Now 'update-available' is protected alongside 'draining' — only explicit
  status transitions are honoured.
- WARNING: Handle 'updating' in _monitor_loop. A worker stuck/crashed in
  'updating' state was never re-onlined or offlined, permanently excluded
  from routing/catalog. Now they are force-offlined after HEARTBEAT_TIMEOUT.
- SUGGESTION: Validate status against allow-list and sanitize drain_reason
  in notification block to prevent operator-facing UI injection.
- SUGGESTION: notify_drain_complete() now sends status='updating' heartbeat
  so the controller transitions from 'draining' to 'updating'.

Test update: test_heartbeat_without_status_reonlines_update_available
renamed to test_heartbeat_without_status_preserves_update_available and
now asserts the corrected protection behavior.

72/72 targeted tests pass.
…ncel arbiter tasks on stale-update

Add 'updating' to the protected status tuple in heartbeat so a periodic
status-less heartbeat doesn't revert an updating worker to 'online', which
would re-enter it into routing/catalog/lease-claims mid-update and prevent
the monitor loop's stale-updating branch from firing.

Mirror the stale-draining branch's GPU arbiter task cancellation in the
stale-updating path so that in-flight arbiter work for force-released
leases isn't orphaned when an updating worker goes stale.

Fixes: PR jaylfc#1903 Kilo review findings (WARNING manager.py:239, SUGGESTION manager.py:835)
@hognek
hognek force-pushed the feat/worker-self-drain branch from 6f28a26 to 8dee96f Compare July 18, 2026 12:53
@hognek

hognek commented Jul 18, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto origin/dev (0e6e6fa). No conflicts — git resolved cleanly. All 222 targeted cluster/worker tests pass. Ready for review.

# Honour worker-initiated status transitions (taOS #890 C2).
if status in ("draining", "update-available", "updating"):
worker.status = status
else:

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: Invalid status value reverts a protected (draining/update-available/updating) worker to "online", defeating the drain protection.

The guard at line 239 only blocks status-less heartbeats. If a worker (or any client that can reach the heartbeat route) sends status="online", status="offline", or any garbage string, the else branch forces worker.status = "online". A draining worker can therefore be re-entered into routing/catalog/lease-claims mid-drain — exactly the #890 failure mode this feature was built to prevent — simply by including a non-None, non-valid status field.

The else branch should only apply when the worker was not in a protected state (or when an explicit "online" transition is intended). Recommended fix: only re-online when status is None and the current status is not protected; for any status not in _VALID_STATUSES, leave the existing protected status untouched rather than resetting to "online".


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

Comment thread tinyagentos/cluster/manager.py Outdated
# update signal before the worker can initiate the drain.
# Only block status-less heartbeats; explicit status transitions
# (e.g. "update-available" → "draining") are still honoured.
if worker.status not in ("draining", "update-available", "updating") or status is not 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: The allow-list of worker-reported statuses is duplicated as inline literals in three places (the guard at line 239, the assignment check at line 241, and _VALID_STATUSES at line 314). Adding a new status later is easy to get wrong — one site missed silently re-opens the drain-bypass and staleness gaps.

Hoist a single module/class-level constant (e.g. WORKER_SELF_REPORTED_STATUSES = frozenset({"draining", "update-available", "updating"})) and reference it in all three spots so the set stays in lockstep.


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

…inst invalid status heartbeat bypass

Kilo found two issues post-rebase on jaylfc#1903:
1. The drain/update status allow-list was duplicated in three places
   (guard condition, assignment check, notification block). Hoist to
   module-level _VALID_STATUSES frozenset and reference in all spots.
2. An invalid status value via heartbeat ('online', 'offline', garbage)
   could revert a protected (draining/update-available/updating) worker
   back to 'online', defeating drain protection. Tighten the guard to
   require status in _VALID_STATUSES when the worker is protected.

Closes jaylfc#1903.

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

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

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

Exercise the production monitor path instead of copying its condition.

The test reimplements _monitor_loop() and therefore cannot detect a regression in that method. Extract a single monitor iteration or run the loop with controlled sleep/cancellation, then assert the real implementation offlines the 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_cluster.py` around lines 547 - 560, Replace the duplicated
heartbeat/offline condition in
test_monitor_loop_offlines_stale_update_available_worker with execution of
ClusterManager’s production _monitor_loop path. Use a single-iteration hook or
controlled sleep and cancellation to process the stale worker, then assert
get_worker("gpu-box").status is "offline".
🤖 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 319-342: Update the recovery notification logic below the shown
status-transition block to emit worker.online only when worker.status is exactly
"online". Preserve the existing recovery behavior for draining and updating
states while preventing a false online event after protected-state recovery.
- Around line 763-764: In the stale-worker handling branch for “online” and
“update-available” statuses, cancel arbiter tasks associated with workers added
to offline_lids after their leases are released. Reuse the existing
task-cancellation mechanism used by stale draining/updating cleanup, ensuring
every offlined update-available worker’s running arbiter tasks is cancelled.

In `@tinyagentos/worker/agent.py`:
- Around line 692-727: Persist the lifecycle status set by
report_update_available, initiate_self_drain, and notify_drain_complete on the
worker, and have periodic heartbeat handling reuse that status after controller
re-registration instead of reverting to online. Keep resending the active status
until the updating lifecycle explicitly clears it or restarts, while preserving
normal status-less heartbeats when no lifecycle status is active.

---

Nitpick comments:
In `@tests/test_cluster.py`:
- Around line 547-560: Replace the duplicated heartbeat/offline condition in
test_monitor_loop_offlines_stale_update_available_worker with execution of
ClusterManager’s production _monitor_loop path. Use a single-iteration hook or
controlled sleep and cancellation to process the stale worker, then assert
get_worker("gpu-box").status is "offline".
🪄 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: 385b446b-b48d-432e-a010-0cac5a55f35c

📥 Commits

Reviewing files that changed from the base of the PR and between 0e6e6fa and 187ca2d.

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

Comment thread tinyagentos/cluster/manager.py
Comment thread tinyagentos/cluster/manager.py
Comment thread tinyagentos/worker/agent.py
…cel arbiter on stale-update-available, gate online event

- worker/agent.py: store lifecycle_status/lifecycle_reason in WorkerAgent,
  resend on periodic heartbeats so controller restart doesn't revert
  draining/updating workers to 'online' (CodeRabbit MAJOR #1)
- manager.py: cancel GPU arbiter tasks on stale-update-available worker
  timeout, matching the cancellation already done for stale-drain and
  stale-update paths (CodeRabbit MAJOR #2)
- manager.py: gate recovery 'worker.online' event on
  worker.status == 'online' to suppress false events for workers
  recovering into draining/updating state (CodeRabbit MINOR jaylfc#3)
- tests/test_worker.py: accept **kwargs in fake_heartbeat mocks to
  match expanded heartbeat() signature

@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

🤖 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/worker/agent.py`:
- Around line 736-738: Update the immediate heartbeat call in the
drain-completion lifecycle transition to pass the drain reason explicitly via
its drain_reason argument, reusing the reason represented by _lifecycle_reason
so the controller receives it on the transition heartbeat.
🪄 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: 9f3e36cd-d886-4659-a3b7-209d0738cf2b

📥 Commits

Reviewing files that changed from the base of the PR and between 187ca2d and 15ab119.

📒 Files selected for processing (3)
  • tests/test_worker.py
  • tinyagentos/cluster/manager.py
  • tinyagentos/worker/agent.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • tinyagentos/cluster/manager.py

Comment on lines +736 to +738
self._lifecycle_status = "updating"
self._lifecycle_reason = "drain-complete"
return await self.heartbeat(status="updating")

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

Pass the drain reason to the immediate heartbeat.

Although _lifecycle_reason is set, the immediate heartbeat() call does not include drain_reason. The _lifecycle_reason will only take effect on subsequent periodic heartbeats from the run() loop. As per the relevant code snippets from tinyagentos/cluster/manager.py, the controller emits the notification on the transition (which occurs on this immediate heartbeat), so it will receive None and display the reason as "unspecified".

💚 Proposed fix
     async def notify_drain_complete(self) -> int:
         """Notify the controller that the worker's drain is complete.
 
         After in-flight work finishes, the worker sends one final
         heartbeat with status="updating" to signal readiness for
         the update. The controller can then proceed with the update
         deploy.
 
         Returns the HTTP status code from the controller.
         """
         logger.info("worker '%s': drain complete, ready for update", self.name)
         self._lifecycle_status = "updating"
         self._lifecycle_reason = "drain-complete"
-        return await self.heartbeat(status="updating")
+        return await self.heartbeat(status="updating", drain_reason=self._lifecycle_reason)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
self._lifecycle_status = "updating"
self._lifecycle_reason = "drain-complete"
return await self.heartbeat(status="updating")
self._lifecycle_status = "updating"
self._lifecycle_reason = "drain-complete"
return await self.heartbeat(status="updating", drain_reason=self._lifecycle_reason)
🤖 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/worker/agent.py` around lines 736 - 738, Update the immediate
heartbeat call in the drain-completion lifecycle transition to pass the drain
reason explicitly via its drain_reason argument, reusing the reason represented
by _lifecycle_reason so the controller receives it on the transition heartbeat.

@jaylfc
jaylfc merged commit cfa1234 into jaylfc:dev Jul 18, 2026
9 checks passed
hognek added a commit to hognek/tinyagentos that referenced this pull request Jul 19, 2026
jaylfc#890 C3)

- worker/self_update.py: SelfUpdateManager orchestrating the full
  worker-initiated update lifecycle: checkpoint → drain → pull → deps
  → migrate → restart → health-check → outcome signal / rollback.

- scripts/taos-deploy-helper.sh: new subcommands checkpoint, rollback,
  restart-self, health-check with internal service helpers. Platform-
  aware (systemd + launchd), respects TAOS_* env vars.

- worker/deploy.py: allowlisted new deploy-helper subcommands.

- routes/cluster.py: POST /api/cluster/workers/{name}/update-outcome
  to record success/rollback outcomes from self-updating workers.
  HMAC-signed, fires notifications on rollback.

- tests: 22 unit tests for checkpoint/marker/rollback/deps/full-flow,
  4 endpoint tests for update-outcome (success, rollback, 404, 400).

Builds on C2 (feat/worker-self-drain) drain protocol. Depends on:
C0 (design doc jaylfc#1896), C2 (drain jaylfc#1903, blocked on review).
hognek added a commit to hognek/tinyagentos that referenced this pull request Jul 19, 2026
…tor, fast-forward branch (jaylfc#890 C3)

BLOCKER 1 — restart bricks the worker:
- cmd_restart_self: use systemctl restart (single job) instead of
  stop+sleep+start to avoid cgroup kill (KillMode=control-group)
- cmd_rollback: skip _stop_worker_service before checkout and use
  systemctl restart at the end, avoiding same cgroup kill issue
  (worker is already drained at this phase)

BLOCKER 2 — orchestrator is dead code:
- Wire post_update_startup hook into WorkerAgent.run() — runs on
  first registration after restart when update marker exists
- Add _check_update_trigger() — file-based signal (update-trigger.json
  in state_dir) checked on each heartbeat cycle; triggers run_full_update

Fix jaylfc#3:
- pull_update: fast-forward local branch ref after origin/<ref>
  checkout to avoid leaving repo in detached HEAD
- run_health_check: drop unused timeout arg (helper no longer reads it)

Rebase: onto current origin/dev (clean — no conflicts).
Post-jaylfc#1903 rebase pending merge of dependency PR jaylfc#1903 (C2 drain).

Tests: 68/68 pass (test_worker_self_update, test_cluster, test_register_all_routers)
hognek added a commit to hognek/tinyagentos that referenced this pull request Jul 19, 2026
jaylfc#890 C3)

- worker/self_update.py: SelfUpdateManager orchestrating the full
  worker-initiated update lifecycle: checkpoint → drain → pull → deps
  → migrate → restart → health-check → outcome signal / rollback.

- scripts/taos-deploy-helper.sh: new subcommands checkpoint, rollback,
  restart-self, health-check with internal service helpers. Platform-
  aware (systemd + launchd), respects TAOS_* env vars.

- worker/deploy.py: allowlisted new deploy-helper subcommands.

- routes/cluster.py: POST /api/cluster/workers/{name}/update-outcome
  to record success/rollback outcomes from self-updating workers.
  HMAC-signed, fires notifications on rollback.

- tests: 22 unit tests for checkpoint/marker/rollback/deps/full-flow,
  4 endpoint tests for update-outcome (success, rollback, 404, 400).

Builds on C2 (feat/worker-self-drain) drain protocol. Depends on:
C0 (design doc jaylfc#1896), C2 (drain jaylfc#1903, blocked on review).
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