Skip to content

feat(worker): self-update install, restart, health-check, and rollback (#890 C3) - #1910

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

feat(worker): self-update install, restart, health-check, and rollback (#890 C3)#1910
hognek wants to merge 4 commits into
jaylfc:devfrom
hognek:feat/worker-self-update-rollback

Conversation

@hognek

@hognek hognek commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Summary

Implements the worker-side self-install, restart, re-register, and rollback flow for taOS Issue #890.

Builds on C2 (PR #1903, feat/worker-self-drain) drain protocol.

Changes

New module: tinyagentos/worker/self_update.py (502 lines)

  • SelfUpdateManager orchestrates the full worker-initiated update lifecycle
  • Pre-restart phases: checkpoint → signal update-available → initiate self-drain → wait for drain → pull new code → update deps → run migrations → restart
  • Post-restart hook: post_update_startup() — reads in-progress marker, waits grace period, runs health-check, signals success or triggers rollback
  • Checkpoint-based rollback: git tag + manifest (SHA, deps snapshot, package manager)
  • Package manager detection (uv vs pip)

Extended: scripts/taos-deploy-helper.sh (+221 lines)

  • New subcommands: checkpoint, rollback [<tag>], restart-self, health-check
  • Platform-aware service management: systemd (tinyagentos-worker / taos-worker) + launchd (macOS)
  • Internal helpers: _stop_worker_service, _start_worker_service

Extended: tinyagentos/worker/deploy.py (+5 lines)

  • Added new subcommands to ALLOWED_COMMANDS for security allowlist

New endpoint: routes/cluster.py (+82 lines)

  • POST /api/cluster/workers/{name}/update-outcome — worker reports success or rollback
  • HMAC-signed (requires worker pairing)
  • Fires worker.update-rollback notification on failure

Tests (26 new, all pass)

  • 22 unit tests in tests/test_worker_self_update.py:
    • Update marker I/O (write, read, clear, invalid JSON, nested dirs)
    • Package manager detection (pip default, uv when lockfile present)
    • Checkpoint creation (success + failure paths)
    • Git pull (fetch fail, checkout fail, success)
    • Dependency update (pip install, pip not found)
    • Full update flow integration (success, checkpoint fail, pull fail)
    • Post-update startup (no marker, health pass, health fail → rollback)
  • 4 endpoint tests in tests/test_cluster.py:
    • Success outcome, rollback outcome, 404 worker not found, 400 unknown outcome

Depends on

Test results

tests/test_worker_self_update.py .............. 22 passed
tests/test_cluster.py::TestUpdateOutcomeEndpoint .... 4 passed
tests/test_register_all_routers.py .... 4 passed

All existing cluster + router tests also pass.

Bot review status (2026-07-18)

Kilo (kilo-code-bot): ✅ All findings resolved across 5 review cycles.

  • Initial: 2 WARNING + 4 SUGGESTION (commit b74d501)
  • Cycle 2: 2 SUGGESTION — detached HEAD, health-check timeout (04de66b)
  • Cycle 3: 1 SUGGESTION — git branch -f rc ignored (6b583bc)
  • Cycle 4: 1 WARNING + 1 SUGGESTION — drain heartbeat, branch rc (a089e02)
  • Final (b2433d3): 0 CRITICAL, 0 WARNING, 0 new SUGGESTION — recommends merge

CodeRabbit: Rate-limited — no review produced.

Gitar: Working (pending).

Qodo: Paused for this user.

Summary by CodeRabbit

  • New Features

    • Added worker self-update workflows with checkpoints, dependency re-sync, health verification, and automatic rollback.
    • Added update outcome reporting with success and rollback acknowledgements.
    • Added commands for checkpointing, rollback, self-restart, and health checks.
    • Enhanced heartbeat responses with drain completion status.
    • Added safeguards to prevent overlapping worker updates.
  • Bug Fixes

    • Improved drain completion behavior so workers aren’t marked as updating prematurely.
  • Tests

    • Added coverage for update outcomes and the complete self-update lifecycle.

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

Adds worker self-update orchestration with checkpointing, dependency updates, draining, restart, health verification, rollback, and controller outcome reporting. Worker heartbeats expose drain completion, and update triggers run during heartbeat cycles.

Changes

Worker self-update lifecycle

Layer / File(s) Summary
Checkpoint, rollback, and health commands
scripts/taos-deploy-helper.sh, tinyagentos/worker/deploy.py
Adds checkpoint manifests, rollback installation, detached restart, health checks, and command allowlist updates.
Update orchestration and rollback state
tinyagentos/worker/self_update.py, tests/test_worker_self_update.py
Implements update markers, ref checkout, dependency updates, drain polling, restart handling, rollback, outcome signaling, post-restart health checks, and tests.
Heartbeat, trigger, and outcome integration
tinyagentos/routes/cluster.py, tinyagentos/worker/agent.py, tests/test_cluster.py
Adds drain completion reporting, per-worker update locking, authenticated outcome handling, trigger processing, startup outcome handling, and endpoint tests.

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

Sequence Diagram(s)

sequenceDiagram
  participant WorkerAgent
  participant ClusterController
  participant DeployHelper
  WorkerAgent->>ClusterController: report update-available
  ClusterController-->>WorkerAgent: return drain_complete heartbeat state
  WorkerAgent->>DeployHelper: create checkpoint and restart worker
  DeployHelper-->>WorkerAgent: return health-check result
  WorkerAgent->>ClusterController: report success or rollback outcome
Loading

Possibly related PRs

  • jaylfc/taOS#1896: Describes the worker auto-update, checkpoint, rollback, drain, health-check, and outcome protocol implemented here.
  • jaylfc/taOS#1903: Adds related worker lifecycle, drain, and heartbeat behavior used by self-update orchestration.
  • jaylfc/taOS#1859: Contains related worker self-update and draining changes in the cluster routes.

Suggested reviewers: jaylfc

🚥 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 and concisely summarizes the worker self-update features added by the pull request.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
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 17, 2026

Copy link
Copy Markdown

Gitar is working

Gitar

Comment thread tinyagentos/worker/self_update.py Outdated
"output": f"git fetch origin {target_ref} failed",
"exit_code": rc,
}
branch = target_ref

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: For a plain branch name (e.g. master), the new remote commits are fetched into origin/master but the worker then checks out the local master branch, which the fetch does NOT advance. The worker will therefore stay on the old commit and the update effectively no-ops. Also, for origin/<branch> refs the code checks out the detached remote-tracking ref rather than a local branch. Resolve by checking out FETCH_HEAD (or remote/branch) after fetch, e.g. git checkout --quiet FETCH_HEAD, or fast-forward the local branch with git checkout --quiet <branch> && git merge --ff-only <remote>/<branch>.

Comment thread tinyagentos/worker/self_update.py Outdated
logger.warning("self-update: drain heartbeat failed — continuing")

logger.info("self-update: drain wait complete (%.0fs elapsed)", elapsed)
return 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.

WARNING: _wait_for_drain unconditionally return True regardless of whether drain actually completed or heartbeats failed. As a result results["drain_wait"]["ok"] never reflects a real problem, and the if not drain_ok warning branch / the comment claiming "proceed anyway" is dead. The loop also never inspects the controller-reported drain/lease state (the args agent is only used to send heartbeats). Either detect completion (e.g. a heartbeat returning the worker's status/pending_leases or a controller endpoint) and return that, or drop the misleading ok semantics.

Comment thread tinyagentos/worker/self_update.py Outdated
import json
import logging
import os
import platform

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: import platform is unused (no other references in the module). Remove it, and also drop the unused from typing import Optional (line 19) and the unused import datetime (line 446) to keep imports honest.

run health-check and signal the outcome."""
marker = {
"checkpoint_tag": checkpoint_tag,
"from_sha": from_sha,

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 marker writes "started_at": None with a comment "filled by caller with ISO timestamp", but no caller ever sets it —run_full_update calls _write_update_marker(...) without a timestamp. This is dead/misleading state. Either populate it (e.g. datetime.datetime.now().isoformat()) or remove the field and the comment.

if outcome == "success":
# Worker is already back online via re-registration.
# Nothing to do — the heartbeat loop handles it.
logger.info("worker '%s' self-update SUCCESS", 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.

SUGGESTION: On outcome == "success" the endpoint does nothing beyond logging — the worker is expected to re-register via heartbeat. That's acceptable, but the worker's state on the controller is never explicitly reconciled/cleared here, and there is no record that the update succeeded (only informational logs + a rollback notification on failure). Consider recording the outcome on the worker object (or emitting a worker.update-success notification) so operators have an auditable trail and the controller can detect a stuck "update-available"/"draining" worker that never reports back.

Comment thread scripts/taos-deploy-helper.sh Outdated
}

cmd_health_check() {
local timeout="${1:-10}"

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: cmd_health_check accepts a timeout argument (${1:-10}) but never uses it — no wait/retry/poll loop honors it. The caller run_health_check() passes POST_RESTART_HEALTH_TIMEOUT (30s), implying a timeout is expected, yet the shell check is instantaneous. Either implement a bounded retry/poll against the port/health endpoint using timeout, or remove the unused parameter to avoid implying a timeout is enforced.

@kilo-code-bot

kilo-code-bot Bot commented Jul 17, 2026

Copy link
Copy Markdown

Code Review Summary

Status: 1 Issue Found | Recommendation: Address before merge

Overview

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

SUGGESTION

File Line Issue
tinyagentos/worker/self_update.py 541 Misleading copy-paste comment: the abort-path lifecycle reset is annotated (CodeRabbit: cross-worker spoofing guard), but the cross-worker spoofing guard is the unrelated HMAC name cross-check in routes/cluster.py. Correct the comment to reference the lifecycle reset.
Previously-reported findings — re-verified on this increment

All findings from the prior review cycle (commit 1ba39cf) were resolved in commit e6a751e0607d208599447150eab46aa4ee82b92a (PATCH 6/6):

  • tinyagentos/worker/agent.py:907 (WARNING: heartbeat-loop blocking) — RESOLVED. The self-update is now launched via a detached asyncio.create_task (_run_detached_update) with an _update_in_progress guard, so the heartbeat loop is never blocked mid-update.
  • tinyagentos/routes/cluster.py:544 (SUGGESTION: expired leases counted in drain_complete) — RESOLVED. The active-lease filter now excludes leases where expires_at <= now, matching get_leases()/find_existing_lease().
  • tinyagentos/routes/cluster.py:1509 (CodeRabbit: cross-worker outcome spoofing) — RESOLVED. report_update_outcome now cross-checks request.state.hmac_worker_name == name and returns 403 on mismatch.
  • tinyagentos/worker/self_update.py:558 (CodeRabbit: lifecycle not reset on abort) — RESOLVED. All three abort paths (pull fail, deps fail, restart fail) now set _lifecycle_status = None/_lifecycle_reason = None and send a regular heartbeat instead of notify_drain_complete().
Files Reviewed (incremental commit e6a751e)
  • scripts/taos-deploy-helper.sh - no issues (brace fix ${repo_dir})
  • tests/test_cluster.py - test-only, benign
  • tests/test_worker_self_update.py - test-only, benign
  • tinyagentos/routes/cluster.py - prior issues resolved, no new issues
  • tinyagentos/worker/agent.py - prior WARNING resolved, no new issues
  • tinyagentos/worker/self_update.py - 1 SUGGESTION (mislabeled comment), abort-reset logic correct

Fix these issues in Kilo Cloud

Previous Review Summaries (8 snapshots, latest commit f967846)

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

Previous review (commit f967846)

Status: 1 Issue Found | Recommendation: Address before merge

Overview

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

SUGGESTION

File Line Issue
tinyagentos/worker/self_update.py 541 Misleading copy-paste comment: the abort-path lifecycle reset is annotated (CodeRabbit: cross-worker spoofing guard), but the cross-worker spoofing guard is the unrelated HMAC name cross-check in routes/cluster.py. Correct the comment to reference the lifecycle reset.
Previously-reported findings — re-verified on this increment

All findings from the prior review cycle (commit 1ba39cf) were resolved in commit e6a751e0607d208599447150eab46aa4ee82b92a (PATCH 6/6):

  • tinyagentos/worker/agent.py:907 (WARNING: heartbeat-loop blocking) — RESOLVED. The self-update is now launched via a detached asyncio.create_task (_run_detached_update) with an _update_in_progress guard, so the heartbeat loop is never blocked mid-update.
  • tinyagentos/routes/cluster.py:544 (SUGGESTION: expired leases counted in drain_complete) — RESOLVED. The active-lease filter now excludes leases where expires_at <= now, matching get_leases()/find_existing_lease().
  • tinyagentos/routes/cluster.py:1509 (CodeRabbit: cross-worker outcome spoofing) — RESOLVED. report_update_outcome now cross-checks request.state.hmac_worker_name == name and returns 403 on mismatch.
  • tinyagentos/worker/self_update.py:558 (CodeRabbit: lifecycle not reset on abort) — RESOLVED. All three abort paths (pull fail, deps fail, restart fail) now set _lifecycle_status = None/_lifecycle_reason = None and send a regular heartbeat instead of notify_drain_complete().
Files Reviewed (incremental commit e6a751e)
  • scripts/taos-deploy-helper.sh - no issues (brace fix ${repo_dir})
  • tests/test_cluster.py - test-only, benign
  • tests/test_worker_self_update.py - test-only, benign
  • tinyagentos/routes/cluster.py - prior issues resolved, no new issues
  • tinyagentos/worker/agent.py - prior WARNING resolved, no new issues
  • tinyagentos/worker/self_update.py - 1 SUGGESTION (mislabeled comment), abort-reset logic correct

Fix these issues in Kilo Cloud

Previous review (commit e6a751e)

Status: 1 Issue Found | Recommendation: Address before merge

Overview

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

SUGGESTION

File Line Issue
tinyagentos/worker/self_update.py 541 Misleading copy-paste comment: the abort-path lifecycle reset is annotated (CodeRabbit: cross-worker spoofing guard), but the cross-worker spoofing guard is the unrelated HMAC name cross-check in routes/cluster.py. Correct the comment to reference the lifecycle reset.
Previously-reported findings — re-verified on this increment

All findings from the prior review cycle (commit 1ba39cf) were resolved in commit e6a751e0607d208599447150eab46aa4ee82b92a (PATCH 6/6):

  • tinyagentos/worker/agent.py:907 (WARNING: heartbeat-loop blocking) — RESOLVED. The self-update is now launched via a detached asyncio.create_task (_run_detached_update) with an _update_in_progress guard, so the heartbeat loop is never blocked mid-update.
  • tinyagentos/routes/cluster.py:544 (SUGGESTION: expired leases counted in drain_complete) — RESOLVED. The active-lease filter now excludes leases where expires_at <= now, matching get_leases()/find_existing_lease().
  • tinyagentos/routes/cluster.py:1509 (CodeRabbit: cross-worker outcome spoofing) — RESOLVED. report_update_outcome now cross-checks request.state.hmac_worker_name == name and returns 403 on mismatch.
  • tinyagentos/worker/self_update.py:558 (CodeRabbit: lifecycle not reset on abort) — RESOLVED. All three abort paths (pull fail, deps fail, restart fail) now set _lifecycle_status = None/_lifecycle_reason = None and send a regular heartbeat instead of notify_drain_complete().
Files Reviewed (incremental commit e6a751e)
  • scripts/taos-deploy-helper.sh - no issues (brace fix ${repo_dir})
  • tests/test_cluster.py - test-only, benign
  • tests/test_worker_self_update.py - test-only, benign
  • tinyagentos/routes/cluster.py - prior issues resolved, no new issues
  • tinyagentos/worker/agent.py - prior WARNING resolved, no new issues
  • tinyagentos/worker/self_update.py - 1 SUGGESTION (mislabeled comment), abort-reset logic correct

Fix these issues in Kilo Cloud

Previous review (commit 1ba39cf)

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/worker/agent.py 886 The full self-update lifecycle runs inline in the single heartbeat loop; long phases (dependency install up to 300s, drain wait up to 120s) block heartbeat sends, so the controller (HEARTBEAT_TIMEOUT=30s) marks the worker offline mid-update, which can break the post-restart re-registration/rollback flow. Move the update to a detached asyncio.create_task and/or keep heartbeating (status updating) during the update.

SUGGESTION

File Line Issue
tinyagentos/routes/cluster.py 542 drain_complete counts every lease in cluster._leases including expired/unswept ones, unlike get_leases()/find_existing_lease() which filter by expires_at > now. This forces the full 120s drain timeout instead of early completion. Filter by expiry.
Previously-reported findings (re-verified)

The two prior-cycle WARNINGs on tinyagentos/worker/self_update.py are confirmed resolved in the current code:

  • git branch -f return code is now captured (rc != 0 -> warning + {"ok": False}), lines 270-298.
  • _wait_for_drain now sends a single heartbeat POST reporting real psutil.cpu_percent() / 100.0 instead of a redundant second POST, lines 626-663.
Files Reviewed (7 files, full PR diff vs merge-base)
  • tinyagentos/worker/self_update.py - 2 prior WARNINGs verified resolved, no new issues
  • tinyagentos/worker/agent.py - 1 WARNING (heartbeat-loop blocking)
  • tinyagentos/routes/cluster.py - 1 SUGGESTION (drain_complete expired-lease count)
  • scripts/taos-deploy-helper.sh - no new issues
  • tinyagentos/worker/deploy.py - no new issues (allowlist additions)
  • tests/test_worker_self_update.py - test-only, benign
  • tests/test_cluster.py - test-only, benign

Fix these issues in Kilo Cloud

Previous review (commit b2433d3)

Status: No new issues in incremental diff | 2 previous WARNINGs resolved | Recommendation: Merge incremental changes

Overview

Severity Count
CRITICAL 0
WARNING 0 (2 resolved)
SUGGESTION 0 new
Resolved since last review (click to expand)

WARNING (resolved)

File Line Issue
tinyagentos/worker/self_update.py 268-277, 283-292 git branch -f return code was ignored, masking fast-forward failure as success. Now captured (rc != 0 → logs warning and returns {"ok": False, ...}). Fixed in this diff.
tinyagentos/worker/self_update.py 620-626 Drain wait fired a redundant second heartbeat POST (doubling traffic) and hardcoded "load": 0.0, clobbering the real load metric. Consolidated to a single heartbeat POST that reports real psutil.cpu_percent() / 100.0. Fixed in this diff.
Carried-forward suggestions (unchanged lines, not re-reported)
  • tinyagentos/worker/self_update.py (N/A): import platform unused.
  • tinyagentos/worker/self_update.py (154): marker writes "started_at": None.
  • tinyagentos/routes/cluster.py (1352): success outcome does not ... (existing).
  • scripts/taos-deploy-helper.sh (N/A / 383): health-check timeout arg handling.

These remain open but are outside the lines changed in this incremental diff and were not duplicated.

Files Reviewed (incremental diff, 2 files)
  • tinyagentos/worker/self_update.py - 2 WARNINGs resolved, 0 new issues
  • tests/test_worker_self_update.py - test-only mock addition (branch returns 0, ""), benign

Previous review (commit a089e02)

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/worker/self_update.py 624 The new drain_complete check fires a second full heartbeat POST every loop iteration (right after agent.heartbeat()), doubling heartbeat traffic and discarding the first response body that already carries drain_complete. It also hardcodes "load": 0.0, which the controller stores unconditionally (worker.load = load, no None guard), clobbering the worker's real load metric throughout the drain window.

SUGGESTION

File Line Issue
tinyagentos/worker/self_update.py 264 The git branch -f return code is ignored (await _run_git(...) discards rc/out). A fast-forward failure is masked as a successful checkout because the code falls through to rev-parse HEAD and returns {"ok": True}. Capture/log the rc or fail the update so the failure isn't silent.
Incremental changes reviewed since 6b583bc
  • tinyagentos/routes/cluster.py — heartbeat now returns drain_complete computed from active leases. Field-guarding verified: only load is written unconditionally (see WARNING). No new blocking issue.
  • tinyagentos/worker/self_update.py_wait_for_drain rewritten to poll drain_complete; pull_update fetch refspec -- separator removed. New WARNING on redundant heartbeat + load clobber; prior branch -f rc suggestion still open.
  • scripts/taos-deploy-helper.shsystemctl restart --no-block for rollback/restart-self (cgroup-survival). Verified correct; no new issues.
Files Reviewed (3 files in incremental diff)
  • scripts/taos-deploy-helper.sh - 0 new issues
  • tinyagentos/routes/cluster.py - 0 new issues
  • tinyagentos/worker/self_update.py - 1 new issue (+ 1 carried suggestion)

Fix these issues in Kilo Cloud

Previous review (commit 6b583bc)

Status: 1 Issue Found | Recommendation: Address before merge

Overview

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

SUGGESTION

File Line Issue
tinyagentos/worker/self_update.py 262 The git branch -f return code is ignored (await _run_git(...) discards rc/out). A fast-forward failure is masked as a successful checkout because the function falls through to rev-parse HEAD and returns {"ok": True}. Capture/log the rc or fail the update so the failure isn't silent.
Previously Reported Issues — Resolved in this update
  • tinyagentos/worker/self_update.py plain-branch no-op (detached HEAD / stale local branch) — now fast-forwards the local tracking branch via git branch -f (lines 257-270).
  • tinyagentos/worker/self_update.py checkout of origin/<branch> no longer leaves the local ref un-fast-forwarded (comment #3605241041 resolved).
  • scripts/taos-deploy-helper.sh + self_update.py dead timeout arg on run_health_check/cmd_health_check removed (comments #3605191668, #3605241037 resolved).
  • tinyagentos/worker/self_update.py unused imports, started_at marker, and routes/cluster.py success audit were resolved in the prior update (carried forward, files unchanged in this incremental diff).
Files Reviewed (3 files in incremental diff)
  • scripts/taos-deploy-helper.sh - 0 new issues (rollback/restart cgroup-safety rewrite verified; prior timeout gaps resolved)
  • tinyagentos/worker/agent.py - 0 new issues (_check_update_trigger + post-update hook wired correctly)
  • tinyagentos/worker/self_update.py - 1 issue (unchecked branch -f rc)

Fix these issues in Kilo Cloud

Previous review (commit 04de66b)

Status: 2 Issues Found | Recommendation: Address before merge

Overview

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

SUGGESTION

File Line Issue
scripts/taos-deploy-helper.sh 368 Removing local timeout="${1:-10}" leaves run_health_check(timeout)'s positional arg silently ignored — the timeout parameter is now dead/misleading (subprocess timeout still enforced by _run_helper).
tinyagentos/worker/self_update.py 251 Checking out origin/<branch> fixes the stale-local-branch no-op but leaves the repo in detached HEAD; the local branch ref is never fast-forwarded.
Previously Reported Issues — Resolved in this update
  • tinyagentos/worker/self_update.py plain-branch update now fetches + checks out origin/<branch> (no longer a stale-local no-op).
  • _wait_for_drain now returns saw_ok instead of always True; caller at line 486 honors drain_ok.
  • Unused imports platform and Optional removed.
  • started_at marker now populated with a UTC ISO timestamp.
  • routes/cluster.py now emits a worker.update-success notification for the success outcome (audit trail added).
  • scripts/taos-deploy-helper.sh cmd_health_check timeout-handling addressed (see new SUGGESTION above for residual contract gap).
Files Reviewed (3 files in incremental diff)
  • scripts/taos-deploy-helper.sh - 1 issue
  • tinyagentos/routes/cluster.py - 0 issues (success audit fixed; change verified)
  • tinyagentos/worker/self_update.py - 1 issue

Fix these issues in Kilo Cloud

Previous review (commit b74d501)

Status: 6 Issues Found | Recommendation: Address before merge

Overview

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

WARNING

File Line Issue
tinyagentos/worker/self_update.py 250 Plain-branch update fetches remote but checks out the stale local branch — update no-ops; origin/<branch> checkouts detach.
tinyagentos/worker/self_update.py 585 _wait_for_drain always returns True; drain_wait.ok never reflects real failures, dead warning branch.

SUGGESTION

File Line Issue
tinyagentos/worker/self_update.py 18 Unused imports: platform, Optional (19), and datetime (446).
tinyagentos/worker/self_update.py 155 started_at marker field never populated (misleading comment).
tinyagentos/routes/cluster.py 1338 success outcome not recorded on controller/worker; no success audit trail.
scripts/taos-deploy-helper.sh 369 cmd_health_check timeout arg accepted but never used.
Files Reviewed (8 files)
  • scripts/taos-deploy-helper.sh
  • tests/test_cluster.py
  • tests/test_worker_self_update.py
  • tinyagentos/cluster/manager.py
  • tinyagentos/routes/cluster.py
  • tinyagentos/worker/agent.py
  • tinyagentos/worker/deploy.py
  • tinyagentos/worker/self_update.py

Fix these issues in Kilo Cloud


Reviewed by step-3.7-flash · Input: 162.9K · Output: 41K · Cached: 4.3M

log "worker service restarted"
}

cmd_health_check() {

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: Removing local timeout="${1:-10}" here leaves the run_health_check(timeout=...) caller's positional arg (_run_helper(["health-check", str(timeout)]) in self_update.py:341) silently ignored.

The timeout parameter is now dead: the shell function no longer reads $1, and the value is never used. The actual subprocess timeout is still enforced by _run_helper's default, so there is no functional break — but the Python-side contract that "the helper applies the health-check timeout" is now misleading. Either drop the timeout parameter from run_health_check, or make the shell actually honor it (e.g. bound the systemctl is-active/port checks with a deadline).


"output": f"git fetch origin {target_ref} failed",
"exit_code": rc,
}
branch = f"origin/{target_ref}"

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: Checking out origin/<branch> fixes the stale-local-branch no-op, but it leaves the working tree in a detached HEAD state. The local <branch> ref is never fast-forwarded, so a subsequent plain-branch update will re-fetch but still check out the remote ref, and any later operation that assumes an attached branch (e.g. tag creation on the branch, future auto-pulls) may behave unexpectedly.

Consider updating the local branch ref instead, e.g. git fetch origin <branch>:<branch> (ff-only) followed by a non-detached checkout, or git branch -f <branch> origin/<branch> after checkout, so the update lands on the branch rather than a detached commit.


@jaylfc

jaylfc commented Jul 17, 2026

Copy link
Copy Markdown
Owner

Two blockers plus the merge conflict (rebase onto post-#1903 dev — this is stacked on #1903 and duplicates its commit).

BLOCKER 1 — restart bricks the worker. scripts/taos-deploy-helper.sh cmd_restart_self runs _stop_worker_service (systemctl stop tinyagentos-worker.service) → sleep → _start_worker_service, invoked as a sudo taos-deploy-helper child of the worker process, inside that service's systemd cgroup. systemctl stop tears down the whole control-group (default KillMode=control-group), so the helper is SIGTERM'd mid-run — _start_worker_service never executes and the worker stays down permanently. The same flaw poisons rollback: _stop_worker_service kills the caller before signal_update_outcome can fire. The pre-existing cmd_update_worker correctly uses systemctl restart (one systemd-owned job that survives). Fix: use systemctl restart as a single job, or detach via systemd-run --scope / a oneshot unit.

BLOCKER 2 — the orchestrator is dead code. run_full_update/post_update_startup/restart_service/run_health_check in self_update.py have no caller (no import of self_update in agent.py or any route; the agent.py diff is byte-identical to #1903). Nothing initiates an update and nothing runs the post-restart health-check/rollback — as merged the feature does nothing at runtime. Either wire the trigger + boot-time post_update_startup hook in this PR, or re-scope it as 'library only, wiring in C4' in the description. It must not be presented as a working self-update.

Lower: pull_update plain-branch form (origin <branch> then git checkout <branch>) no-ops on a stale local ref — the trigger must pass origin/<ref>. _wait_for_drain always returns True after the full 120s sleep (never short-circuits, drain_wait.ok is meaningless). Tests never catch the brick because _run_helper is fully mocked.

@hognek

hognek commented Jul 17, 2026

Copy link
Copy Markdown
Contributor Author

Review-blocker resolution (6b583bc)

BLOCKER 1 — restart bricks the worker

  • cmd_restart_self: replaced stop+sleep+start with a single systemctl restart call (tries both tinyagentos-worker.service and taos-worker.service). This avoids the cgroup kill issue — systemctl restart is a single systemd-owned job that survives.
  • cmd_rollback: removed _stop_worker_service before checkout and _start_worker_service at the end. Replaced with systemctl restart at the end. The worker is already drained at this phase, so running old code briefly during checkout+dep-reinstall is safe.

BLOCKER 2 — orchestrator is dead code

  • post_update_startup now wired into WorkerAgent.run() — called after successful registration when an update-in-progress marker exists (handles health-check + outcome signal / rollback on restart).
  • _check_update_trigger() added to WorkerAgent — checks for update-trigger.json in the state dir on each heartbeat cycle. When found, atomically removes the trigger and calls run_full_update. Trigger format: {"target_ref": "origin/dev", "graceful": true}.
  • This gives two call paths: (1) boot-time post_update_startup for restart recovery, (2) runtime trigger-file for initiating updates.

Fix — pull_update detached HEAD

  • After git checkout origin/<branch>, now also runs git branch -f <branch> origin/<branch> to fast-forward the local tracking branch. Repo no longer left in detached HEAD.

Fix — run_health_check unused timeout arg

  • Dropped the unused timeout parameter from the Python side (the helper already stopped reading it in the last commit).

Rebase — onto current origin/dev: clean, no conflicts. Full post-#1903 rebase is pending merge of PR #1903 (C2 drain).

Tests: 68/68 pass (test_worker_self_update, test_cluster, test_register_all_routers).

# after a direct origin/<ref> checkout.
if "/" in target_ref:
local_branch = target_ref.split("/", 1)[1]
await _run_git(

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 git branch -f return code is ignored — await _run_git(...) discards both (rc, out). If the fast-forward fails (e.g. the local branch name collides with a tag or the remote-tracking ref isn't resolvable), the function still falls through to rev-parse HEAD and returns {"ok": True, ...}, silently reporting a successful checkout while the repo may remain in a detached/undesired state. Capture and log the rc (or fail the update) so a fast-forward failure isn't masked as success.


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

path = "/api/cluster/heartbeat"
payload = _json.dumps({
"name": name,
"load": 0.0,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

WARNING: This drain-complete check fires a second, full heartbeat POST on every loop iteration, right after agent.heartbeat(...) already sent one. The first heartbeat's response body — which now carries drain_complete — is thrown away (because heartbeat() only returns the status code), so each 5s tick doubles heartbeat traffic to the controller and re-signs/re-sends a redundant request.

Additionally, this lightweight payload hardcodes "load": 0.0. The controller's ClusterManager.heartbeat() assigns worker.load = load unconditionally (no None guard, unlike backends/capabilities/vram), so every drain-window tick overwrites the worker's real load metric with 0.0, corrupting operator/routing views for the duration of the drain.

Prefer reading drain_complete from the response of the single heartbeat that is already being sent (e.g. have heartbeat() return the parsed body or expose the last response) instead of issuing this duplicate POST. If a separate request is unavoidable, omit load so the cached value is preserved.


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

hognek added a commit to hognek/tinyagentos that referenced this pull request Jul 17, 2026
…ranch -f

- Replace redundant dual-POST heartbeat pattern in _wait_for_drain with a
  single POST that sends real CPU load (via psutil) and reads drain_complete
  from the response body. Eliminates heartbeat traffic doubling and load=0.0
  clobber (Kilo WARNING).

- Check git branch -f return code in pull_update and fail the update early
  when the fast-forward fails instead of silently masking the error
  (Kilo SUGGESTION).

- Update test_pull_success mock to handle the branch git subcommand.

Refs: jaylfc#1910
hognek added a commit to hognek/tinyagentos that referenced this pull request Jul 19, 2026
…ranch -f

- Replace redundant dual-POST heartbeat pattern in _wait_for_drain with a
  single POST that sends real CPU load (via psutil) and reads drain_complete
  from the response body. Eliminates heartbeat traffic doubling and load=0.0
  clobber (Kilo WARNING).

- Check git branch -f return code in pull_update and fail the update early
  when the fast-forward fails instead of silently masking the error
  (Kilo SUGGESTION).

- Update test_pull_success mock to handle the branch git subcommand.

Refs: jaylfc#1910
@hognek
hognek force-pushed the feat/worker-self-update-rollback branch from b2433d3 to 1ba39cf Compare July 19, 2026 23:04
Comment thread tinyagentos/worker/agent.py Outdated
# Check for pending self-update trigger on each heartbeat
# cycle. The check is cheap (a file stat) and the trigger
# file is removed atomically before the update runs.
await self._check_update_trigger()

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 full self-update lifecycle runs inline in the single heartbeat loop, blocking heartbeat sends during long phases.

_check_update_trigger() awaits run_full_update(), whose phases run sequentially in this while self._running loop. The dependency-install phase (update_dependencies) can take well over the controller's HEARTBEAT_TIMEOUT = 30s (see tinyagentos/cluster/manager.py:15), and the drain wait is up to 120s. While these phases await subprocesses, the loop cannot send the next heartbeat, so the controller marks the worker offline mid-update (manager.py:767). A worker marked offline may be de-registered, and the post-restart hook depends on successful re-registration — so an eviction can break the whole self-update/rollback flow.

Consider moving the update into a detached asyncio.create_task(...) (fire-and-forget) instead of awaiting it inside the heartbeat loop, and/or continuing to send periodic heartbeats (status updating) while the update runs so the controller never sees a 30s gap.

Note also: the trigger is only checked after a successful heartbeat while _registered is True (line 886 is reached before the status == 404 -> _registered = False branch at line 887+). If the controller forgot the worker (404), the trigger will not fire until re-registration succeeds.

worker = cluster._workers.get(body.name)
drain_complete = False
if worker is not None and worker.status == "draining" and hasattr(cluster, "_leases"):
active = [

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_complete counts every lease in cluster._leases, including expired ones not yet swept by _sweep_expired_leases.

The active-lease filter at lines 542-547 iterates cluster._leases.items() directly, but elsewhere the codebase treats expired leases as gone — e.g. get_leases() (manager.py:529-532) and find_existing_lease() (manager.py:432-438) both filter on lease.expires_at > now. If a lease has expired but has not yet been swept by the monitor loop, it is still counted here, so drain_complete stays False and the worker waits the full 120s _wait_for_drain timeout instead of proceeding early — defeating the purpose of the drain_complete optimization.

Filter by expiry to match the rest of the code:

Suggested change
active = [
if worker is not None and worker.status == "draining" and hasattr(cluster, "_leases"):
now = time.time()
active = [
lid for lid, lease in cluster._leases.items()
if getattr(lease, "expires_at", 0) > now
and (parsed := cluster._parse_resource_id(lease.resource_id))
and parsed[0] == body.name
]
drain_complete = len(active) == 0

@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 (1)
scripts/taos-deploy-helper.sh (1)

348-348: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Brace the extras suffix to avoid array-subscript ambiguity (SC1087).

"$repo_dir[worker]" parses correctly in Bash today (variable expansion stops at [), but shellcheck flags it because [worker] reads like an array subscript. Use ${repo_dir} to make the pip extras path unambiguous.

♻️ Proposed tweak
-                "$venv/bin/pip" install -q -e "$repo_dir[worker]" 2>/dev/null || \
+                "$venv/bin/pip" install -q -e "${repo_dir}[worker]" 2>/dev/null || \
🤖 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 `@scripts/taos-deploy-helper.sh` at line 348, Update the pip install command
around "$venv/bin/pip" to brace the repo_dir variable before appending the
[worker] extras suffix, making the editable-install path unambiguous without
changing its 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/routes/cluster.py`:
- Around line 1491-1500: Update the handler containing require_worker_hmac and
the cluster.get_worker(name) lookup to compare request.state.hmac_worker_name
with the requested worker name before performing any lookup or outcome action.
Return the same 403 response used by worker_heartbeat for mismatched identities,
while preserving the existing HMAC failure and valid-worker behavior.

In `@tinyagentos/worker/self_update.py`:
- Around line 533-551: Reset the worker lifecycle status before returning from
both failure paths in the self-update flow: the `if not pull["ok"]` branch after
`pull_update(target_ref)` and the `if not deps["ok"]` branch after
`update_dependencies()`. Clear `_lifecycle_status` or send an `online` heartbeat
before `notify_drain_complete()` so the worker is routable again after aborting.

---

Nitpick comments:
In `@scripts/taos-deploy-helper.sh`:
- Line 348: Update the pip install command around "$venv/bin/pip" to brace the
repo_dir variable before appending the [worker] extras suffix, making the
editable-install path unambiguous without changing its 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: e71ddb3c-73fa-415a-bd54-b0f49c73ecfa

📥 Commits

Reviewing files that changed from the base of the PR and between c801a22 and 1ba39cf.

📒 Files selected for processing (7)
  • scripts/taos-deploy-helper.sh
  • tests/test_cluster.py
  • tests/test_worker_self_update.py
  • tinyagentos/routes/cluster.py
  • tinyagentos/worker/agent.py
  • tinyagentos/worker/deploy.py
  • tinyagentos/worker/self_update.py

Comment thread tinyagentos/routes/cluster.py
Comment thread tinyagentos/worker/self_update.py
@hognek
hognek force-pushed the feat/worker-self-update-rollback branch from 1ba39cf to a089e02 Compare July 19, 2026 23:37
hognek added a commit to hognek/tinyagentos that referenced this pull request Jul 19, 2026
…fc#1910)

- agent.py: move self-update to detached asyncio.create_task so the
  heartbeat loop is never blocked during long update phases; add
  _update_in_progress guard to prevent duplicate launches
  (Kilo WARNING: heartbeat-loop blocking)

- cluster.py (routes): filter drain_complete by lease expiry to match
  get_leases()/find_existing_lease() behavior, avoiding the full 120s
  timeout when expired leases haven't been swept yet
  (Kilo SUGGESTION: expired leases)

- cluster.py (routes): add HMAC worker-name cross-check in
  report_update_outcome so a valid worker can't spoof another worker's
  outcome; matches existing pattern in worker_heartbeat
  (CodeRabbit Major: cross-worker spoofing guard)

- self_update.py: reset _lifecycle_status to None and send a regular
  heartbeat instead of notify_drain_complete() in all three abort paths
  (pull failure, deps failure, restart failure) so the worker is
  routable again instead of stuck in 'updating'
  (CodeRabbit Major: lifecycle reset on abort)

- deploy-helper.sh: brace $repo_dir variable to avoid SC1087 ambiguity
  (CodeRabbit Nitpick: shell variable bracing)

Tests: 68/68 pass (test_worker_self_update, test_cluster, test_register_all_routers)
@hognek
hognek force-pushed the feat/worker-self-update-rollback branch from a089e02 to e6a751e Compare July 19, 2026 23:38
Comment thread tinyagentos/worker/self_update.py Outdated
logger.error("self-update: pull failed — aborting update")
# Don't rollback yet — we haven't installed anything.
# Reset lifecycle so the worker is routable again instead of
# stuck in "updating" (CodeRabbit: cross-worker spoofing guard).

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: Misleading copy-paste comment.

This block resets the worker's lifecycle so it is routable again after an abort (the change referenced in the commit body as "lifecycle reset on abort"). The trailing (CodeRabbit: cross-worker spoofing guard) note is wrong here — the cross-worker spoofing guard is the HMAC name cross-check added in routes/cluster.py, unrelated to this abort path. Fix the comment so future maintainers are not misled about what this code does.

Suggested change
# stuck in "updating" (CodeRabbit: cross-worker spoofing guard).
# stuck in "updating" (CodeRabbit: lifecycle reset on abort).

@jaylfc

jaylfc commented Jul 27, 2026

Copy link
Copy Markdown
Owner

@hognek Rebase needed - this has conflicts against current dev (GitHub reports mergeable_state: dirty), so it cannot merge as-is even though the required checks are green. Green checks on a conflicted branch test the old base, not the merge result.

Everything else on it looks fine from my side; it is purely staleness. Rebase onto current dev and I will take another look.

@hognek
hognek force-pushed the feat/worker-self-update-rollback branch from e6a751e to 90326d7 Compare July 27, 2026 08:00
@jaylfc

jaylfc commented Jul 28, 2026

Copy link
Copy Markdown
Owner

Flagging this from the periodic repo sweep: this PR is red on both required checks, and it is a real failure rather than infra.

shards (3.13, 2) fails four tests, all the same shape:

FAILED tests/test_cluster.py::TestUpdateOutcomeEndpoint::test_update_outcome_success - assert 403 == 200
FAILED tests/test_cluster.py::TestUpdateOutcomeEndpoint::test_update_outcome_rollback - assert 403 == 200
FAILED tests/test_cluster.py::TestUpdateOutcomeEndpoint::test_update_outcome_worker_not_found - assert 403 == 404
FAILED tests/test_cluster.py::TestUpdateOutcomeEndpoint::test_update_outcome_unknown_outcome - assert 403 == 400

Every one gets 403 where the test expects the endpoint to have been reached at all, including the two negative cases that expect 404 and 400. So the update-outcome endpoint is refusing the request before any of its own logic runs. That reads as an auth or permission gate applying to a route the tests exercise unauthenticated, rather than four separate bugs.

Worth checking whether the tests need to authenticate now or the gate is wider than intended. The 403 == 404 case is the useful one to reason from: it proves the refusal happens before the worker lookup.

Not touching it since it is yours. test (3.12) and test (3.13) are both required on dev, so this blocks the merge until it is green.

@hognek

hognek commented Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

Fixed the 4 failing TestUpdateOutcomeEndpoint tests (all returning 403).

Root cause: The tests patched require_worker_hmac to lambda r: None, but the route-level name cross-check at report_update_outcome line 1500 requires request.state.hmac_worker_name to match the path {name} parameter. Since the old side_effect never set it, getattr(request.state, "hmac_worker_name", None) returned None, which never matched the worker name → 403.

Fix: Each test's side_effect now also sets r.state.hmac_worker_name to the correct worker name for that test case.

All 42 cluster tests pass (including the 4 fixed).

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 7

Caution

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

⚠️ Outside diff range comments (2)
tinyagentos/worker/agent.py (2)

733-743: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Aborted update leaves the worker permanently draining.

notify_drain_complete() sends one status-less heartbeat, but self._lifecycle_status / _lifecycle_reason are still "draining" from initiate_self_drain(). The run loop at Line 892 re-sends status=self._lifecycle_status on every subsequent tick, so after an aborted update (pull failed / dependency update failed in self_update.run_full_update) the controller keeps the worker out of routing forever. Clear the fields here.

🔧 Proposed fix
         logger.info("worker '%s': drain complete, ready for update", self.name)
+        self._lifecycle_status = None
+        self._lifecycle_reason = None
         return await self.heartbeat()
🤖 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 733 - 743, Clear the worker’s
lifecycle state in notify_drain_complete() before sending the final heartbeat,
resetting _lifecycle_status and _lifecycle_reason from the draining state so
subsequent run-loop heartbeats no longer advertise the worker as draining after
an aborted update.

117-119: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

WorkerUpdateService is undefined for the annotation (Ruff F821).

The quoted annotation is runtime-safe, but Ruff flags the unresolved name and CI lint will fail. Add a TYPE_CHECKING import.

🔧 Proposed fix
+        # (add near the top of the module)
+        # if TYPE_CHECKING:
+        #     from tinyagentos.worker.update_check import WorkerUpdateService
         self._update_service: "WorkerUpdateService | None" = None
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tinyagentos/worker/agent.py` around lines 117 - 119, Add a TYPE_CHECKING-only
import for WorkerUpdateService so the annotation on Worker._update_service
resolves for Ruff without introducing a runtime import; leave the existing lazy
initialization and run() startup behavior unchanged.

Source: Linters/SAST tools

🧹 Nitpick comments (3)
tests/test_worker_self_update.py (2)

438-439: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Mock signature drifts from run_health_check().

run_health_check() takes no parameters (self_update.py Line 353), but these mocks declare timeout=30. It passes today only because the default absorbs the missing argument, which means the tests won't catch future signature drift. Drop the parameter.

♻️ Proposed tweak
-        async def mock_health_check(timeout=30):
+        async def mock_health_check():
             return {"ok": True, "output": "healthy", "exit_code": 0}

Also applies to: 474-475

🤖 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_worker_self_update.py` around lines 438 - 439, Update the
mock_health_check definitions in both referenced test locations to accept no
parameters, matching the run_health_check signature while preserving their
existing healthy response.

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

No coverage for _wait_for_drain (graceful=True) or signal_update_outcome.

Both tests use graceful=False or abort before drain, so the drain-polling loop and the HMAC-signed outcome POST — the two most failure-prone paths in this module — are untested. Worth adding a case that drives _wait_for_drain with a mocked controller returning drain_complete: true.

🤖 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_worker_self_update.py` around lines 266 - 400, The
TestRunFullUpdate coverage should exercise the graceful drain path and signed
update outcome reporting. Add a success test that calls run_full_update with
graceful=True, mocks the controller interactions used by _wait_for_drain to
return drain_complete=true, and verifies signal_update_outcome is invoked with
the successful result while preserving the existing phase assertions.
tinyagentos/worker/agent.py (1)

930-933: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

stop() reaches into WorkerUpdateService._stop_event.

Private-attribute access across a module boundary; the service already owns an async stop(). Consider exposing a synchronous request_stop() on WorkerUpdateService and calling that.

🤖 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 930 - 933, Update Worker.stop to
avoid directly accessing WorkerUpdateService._stop_event across the module
boundary. Add a synchronous request_stop() method to WorkerUpdateService that
signals its existing stop event, then call self._update_service.request_stop()
from stop() while preserving the current None guard.
🤖 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 `@scripts/taos-deploy-helper.sh`:
- Around line 320-330: Update the systemd-run dispatch branches in the worker
restart helper to use the default transient service mode instead of --scope,
while retaining asynchronous --no-block behavior. Ensure the dispatched
systemctl restart is independent of the worker service cgroup so it can outlive
the cgroup teardown, preserving the existing service fallback order and success
logging.
- Around line 272-281: Bound the suffix retry loop in the tag creation logic
within cmd_checkpoint so it makes only a finite number of attempts. Preserve the
existing counter-suffix behavior for tag collisions, but exit with a clear
failure when repeated git tag operations fail for other reasons instead of
allowing indefinite retries.

In `@tinyagentos/worker/agent.py`:
- Around line 851-873: The heartbeat loop must continue while the post-restart
hook and full update run. Update the startup hook around post_update_startup and
the _check_update_trigger flow so these long-running operations execute as
detached/background tasks, or otherwise emit an updating heartbeat throughout
their await periods, while preserving outcome logging and error handling.

In `@tinyagentos/worker/deploy.py`:
- Around line 33-37: Update the deploy helper contract between
rollback_to_checkpoint() and run_deploy() so checkpoint_tag values are handled
consistently: either allow and parse the tagged “rollback <tag>” form in
run_deploy() for controller-invoked rollbacks, or keep the allowlist restricted
to bare “rollback” and route explicit manual tags through scripts/rollback.sh.
Preserve bare self-update rollback behavior and ensure unsupported command forms
remain rejected.

In `@tinyagentos/worker/self_update.py`:
- Around line 243-255: The plain-branch update path must prevent git option
injection from target_ref. In tinyagentos/worker/self_update.py lines 243-255,
add an argument terminator before target_ref in git fetch and terminate the git
checkout arguments; in tinyagentos/worker/agent.py lines 768-793, validate
target_ref against a conservative ref pattern that rejects leading hyphens
before run_full_update, and delete the update trigger when validation fails.
- Around line 617-644: Update the self-update drain polling flow around
agent.heartbeat() to reuse the existing heartbeat response/body and read its
parsed drain_complete value, removing the second HTTP POST and duplicate request
construction. Ensure the heartbeat payload continues reporting the worker’s real
load rather than overwriting it with 0.0, while preserving the existing
successful drain-confirmation behavior.
- Around line 262-272: Consolidate the target_ref handling in the update flow
around _run_git: derive the branch name by removing the remote prefix when
target_ref contains "/", otherwise use target_ref directly, then perform one
branch -f operation. Capture and validate its return result, propagating failure
so pull_update does not return ok: True while HEAD remains detached.

---

Outside diff comments:
In `@tinyagentos/worker/agent.py`:
- Around line 733-743: Clear the worker’s lifecycle state in
notify_drain_complete() before sending the final heartbeat, resetting
_lifecycle_status and _lifecycle_reason from the draining state so subsequent
run-loop heartbeats no longer advertise the worker as draining after an aborted
update.
- Around line 117-119: Add a TYPE_CHECKING-only import for WorkerUpdateService
so the annotation on Worker._update_service resolves for Ruff without
introducing a runtime import; leave the existing lazy initialization and run()
startup behavior unchanged.

---

Nitpick comments:
In `@tests/test_worker_self_update.py`:
- Around line 438-439: Update the mock_health_check definitions in both
referenced test locations to accept no parameters, matching the run_health_check
signature while preserving their existing healthy response.
- Around line 266-400: The TestRunFullUpdate coverage should exercise the
graceful drain path and signed update outcome reporting. Add a success test that
calls run_full_update with graceful=True, mocks the controller interactions used
by _wait_for_drain to return drain_complete=true, and verifies
signal_update_outcome is invoked with the successful result while preserving the
existing phase assertions.

In `@tinyagentos/worker/agent.py`:
- Around line 930-933: Update Worker.stop to avoid directly accessing
WorkerUpdateService._stop_event across the module boundary. Add a synchronous
request_stop() method to WorkerUpdateService that signals its existing stop
event, then call self._update_service.request_stop() from stop() while
preserving the current None guard.
🪄 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: 010ad996-20ef-4d81-9c30-c5d244ab0bfd

📥 Commits

Reviewing files that changed from the base of the PR and between 1ba39cf and f967846.

📒 Files selected for processing (7)
  • scripts/taos-deploy-helper.sh
  • tests/test_cluster.py
  • tests/test_worker_self_update.py
  • tinyagentos/routes/cluster.py
  • tinyagentos/worker/agent.py
  • tinyagentos/worker/deploy.py
  • tinyagentos/worker/self_update.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • tests/test_cluster.py
  • tinyagentos/routes/cluster.py

Comment on lines +272 to +281
# Tag the current HEAD so it survives a checkout (detached or branch)
local tag="taos-worker-pre-update-$(date -u +%Y%m%d-%H%M%S)"
git -C "$repo_dir" tag "$tag" HEAD 2>/dev/null || {
# Tag already exists — add a counter suffix
local suffix=1
while ! git -C "$repo_dir" tag "${tag}-${suffix}" HEAD 2>/dev/null; do
((suffix++))
done
tag="${tag}-${suffix}"
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Unbounded retry loop can hang forever.

If git tag keeps failing for a reason other than "tag already exists" (disk full, index.lock present, permissions), the while loop spins indefinitely incrementing suffix, hanging cmd_checkpoint — and therefore the entire self-update flow that depends on it completing.

🔒 Bound the retry loop
     git -C "$repo_dir" tag "$tag" HEAD 2>/dev/null || {
         # Tag already exists — add a counter suffix
         local suffix=1
-        while ! git -C "$repo_dir" tag "${tag}-${suffix}" HEAD 2>/dev/null; do
+        while ! git -C "$repo_dir" tag "${tag}-${suffix}" HEAD 2>/dev/null; do
+            if (( suffix >= 20 )); then
+                die "failed to create checkpoint tag after ${suffix} attempts"
+            fi
             ((suffix++))
         done
         tag="${tag}-${suffix}"
     }
📝 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
# Tag the current HEAD so it survives a checkout (detached or branch)
local tag="taos-worker-pre-update-$(date -u +%Y%m%d-%H%M%S)"
git -C "$repo_dir" tag "$tag" HEAD 2>/dev/null || {
# Tag already exists — add a counter suffix
local suffix=1
while ! git -C "$repo_dir" tag "${tag}-${suffix}" HEAD 2>/dev/null; do
((suffix++))
done
tag="${tag}-${suffix}"
}
# Tag the current HEAD so it survives a checkout (detached or branch)
local tag="taos-worker-pre-update-$(date -u +%Y%m%d-%H%M%S)"
git -C "$repo_dir" tag "$tag" HEAD 2>/dev/null || {
# Tag already exists — add a counter suffix
local suffix=1
while ! git -C "$repo_dir" tag "${tag}-${suffix}" HEAD 2>/dev/null; do
if (( suffix >= 20 )); then
die "failed to create checkpoint tag after ${suffix} attempts"
fi
((suffix++))
done
tag="${tag}-${suffix}"
}
🧰 Tools
🪛 Shellcheck (0.11.0)

[warning] 273-273: Declare and assign separately to avoid masking return values.

(SC2155)

🤖 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 `@scripts/taos-deploy-helper.sh` around lines 272 - 281, Bound the suffix retry
loop in the tag creation logic within cmd_checkpoint so it makes only a finite
number of attempts. Preserve the existing counter-suffix behavior for tag
collisions, but exit with a clear failure when repeated git tag operations fail
for other reasons instead of allowing indefinite retries.

Comment on lines +320 to +330
# 2 — systemd-run --scope: runs outside the service cgroup.
if command -v systemd-run >/dev/null 2>&1; then
if systemd-run --scope --no-block systemctl restart tinyagentos-worker.service 2>/dev/null; then
log "worker restart dispatched via systemd-run"
return 0
fi
if systemd-run --scope --no-block systemctl restart taos-worker.service 2>/dev/null; then
log "worker restart dispatched via systemd-run (taos-worker)"
return 0
fi
fi

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

🧩 Analysis chain

🌐 Web query:

Does systemd-run --scope --no-block actually run in the background, or does --scope force synchronous execution regardless of --no-block?

💡 Result:

The short answer is yes, when you use systemd-run --scope, the command execution is inherently synchronous, meaning it will block the caller and return only when the command finishes, regardless of whether you include --no-block [1][2][3]. In the context of systemd-run, the --scope option fundamentally changes how the command is executed compared to the default transient service unit [1][2]. 1. Understanding --scope: When you run a command with --scope, the systemd-run process itself acts as the parent of the command [1][3]. The command inherits the execution environment of the caller and runs synchronously; systemd-run waits for the command to terminate [1][2][4]. This is different from the default service mode, where systemd acts as the parent and manages the process asynchronously in the background [1][2]. 2. The role of --no-block: The --no-block flag is designed to control whether systemd-run waits for the unit's start-up operation to be enqueued and completed [1][2]. While it is effective for default service units, it does not transform the synchronous nature of a scope unit into a background process [1][2]. 3. Why it behaves this way: The developers have noted that --scope is intended as a direct wrapper for the command execution within a transient control group [5]. Because the caller remains the parent process in this mode, it naturally remains attached to the lifecycle of the command [1][3]. If you need a process to run in the background while using --scope, you must handle the backgrounding yourself, for instance, by appending an ampersand (&) to your command string (e.g., systemd-run --scope my-command &) to place the entire systemd-run process in the background [5]. Top results for further reading: [1] https://man.archlinux.org/man/systemd-run.1.en [2] https://freedesktop.org/software/systemd/man/latest/systemd-run.html [5] https://lists.freedesktop.org/archives/systemd-devel/2018-February/040351.html [3] https://manpages.debian.org/trixie/systemd/systemd-run.1.en.html

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate file and relevant section =="
if [ -f scripts/taos-deploy-helper.sh ]; then
  nl -ba scripts/taos-deploy-helper.sh | sed -n '280,355p'
else
  echo "scripts/taos-deploy-helper.sh not found"
fi

echo
echo "== check systemd version availability if installed =="
if command -v systemd-run >/dev/null 2>&1; then
  systemd-run --version | head -n 8 || true
else
  echo "systemd-run not available in sandbox"
fi

echo
echo "== search for related docs/comments in repo =="
rg -n "systemd-run|--scope|--no-block|taos-worker|tinyagentos-worker" . --glob '!node_modules/**' --glob '!dist/**' --glob '!build/**' || true

Repository: jaylfc/taOS

Length of output: 228


🌐 Web query:

Red Hat systemd-run --scope synchronous no-block does not run in background systemd man page

💡 Result:

When you use systemd-run with the --scope option, the command is executed synchronously by design [1][2][3]. In this mode, systemd-run acts as the parent process and waits for the command to finish before it returns [1][4][3]. Because scope units are inherently tied to the lifecycle of the process that created them, they are not designed to be "backgrounded" in the way that transient service units (created without --scope) can be [1][4]. The --no-block option, which is intended to prevent systemd-run from waiting for the unit's initial start-up confirmation, does not override the fundamental synchronous nature of --scope [1][5][3]. While you may attempt to pass both, the manual page indicates that the synchronous execution behavior of --scope is a core aspect of how it manages the command as a child process of the caller [1][2][3]. If your goal is to run a command in the background, you should use the default transient service unit mode (by omitting --scope), which starts the service asynchronously by default [1][6][7].

Citations:


Avoid relying on systemd-run --scope --no-block to stay prompt.

With --scope, the command is synchronous and --no-block does not make it backgrounded, so this dispatch path can still block the deploy script through the whole systemctl restart. Use the default transient service mode for async dispatch and ensure the restart can outlive the worker cgroup teardown.

🤖 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 `@scripts/taos-deploy-helper.sh` around lines 320 - 330, Update the systemd-run
dispatch branches in the worker restart helper to use the default transient
service mode instead of --scope, while retaining asynchronous --no-block
behavior. Ensure the dispatched systemctl restart is independent of the worker
service cgroup so it can outlive the cgroup teardown, preserving the existing
service fallback order and success logging.

Comment on lines +851 to +873

# ── Post-update startup hook (taOS #890 C3) ─────────
# If an update-in-progress marker exists from a
# pre-restart checkpoint, run the health-check and
# signal the outcome (success or rollback) to the
# controller.
try:
from tinyagentos.worker.self_update import post_update_startup
outcome = await post_update_startup(
controller_url=self.controller_url,
agent=self,
state_dir=self._state_dir,
)
if outcome is not None:
logger.info(
"self-update: post-restart outcome=%s",
outcome.get("outcome", "unknown"),
)
except Exception:
logger.warning(
"post_update_startup hook failed — continuing",
exc_info=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.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

The heartbeat loop stalls for the entire update / post-restart hook.

post_update_startup() blocks for POST_RESTART_GRACE_PERIOD (15s) plus the health check, and _check_update_trigger() awaits the whole run_full_update() (drain wait up to 120s, dependency install unbounded). No heartbeat is sent during either, so the controller's heartbeat timeout can mark the worker offline mid-update and de-register it, breaking the very re-registration the post-restart hook depends on. Run both as detached tasks, or keep an updating heartbeat ticking while they execute.

Also applies to: 919-925

🧰 Tools
🪛 Ruff (0.15.21)

[warning] 869-869: Do not catch blind exception: Exception

(BLE001)

🤖 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 851 - 873, The heartbeat loop must
continue while the post-restart hook and full update run. Update the startup
hook around post_update_startup and the _check_update_trigger flow so these
long-running operations execute as detached/background tasks, or otherwise emit
an updating heartbeat throughout their await periods, while preserving outcome
logging and error handling.

Comment on lines +33 to +37
# Worker self-update subcommands (taOS #890 C3).
"checkpoint",
"rollback",
"restart-self",
"health-check",

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Check whether any caller passes a rollback tag through run_deploy().
rg -n 'run_deploy\(' -A2 tinyagentos | rg -n 'rollback'

Repository: jaylfc/taOS

Length of output: 149


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate deploy.py and related helper files =="
fd -a 'deploy\.py$|self_update\.py$|.*deploy.*\.(py|sh)$' tinyagentos 2>/dev/null | sed 's#^\./##'

echo
echo "== tinyagentos/worker/deploy.py relevant lines =="
wc -l tinyagentos/worker/deploy.py
cat -n tinyagentos/worker/deploy.py | sed -n '1,120p'

echo
echo "== run_deploy call sites =="
rg -n 'run_deploy\(' tinyagentos -C 2 || true

echo
echo "== cmd_rollback definitions/usages =="
rg -n 'cmd_rollback|rollback|ALLOWED_COMMANDS' tinyagentos -C 2

Repository: jaylfc/taOS

Length of output: 26964


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== helper rollback implementation =="
fd -a 'taos-deploy-helper|deploy.*helper|deploy-helper|deploy\.sh' /usr/local/bin /usr/local /tmp /home 2>/dev/null | sed 's#^\./##' || true
fd -a 'deploy-helper|deploy.*helper|deploy\.sh'| sed 's#^\./##' | head -50 || true

echo
echo "== local deployment scripts likely containing rollback argument parsing =="
for f in $(fd -a '.*deploy.*' tinyagentos tinyagentos | sed 's#^\./##'); do
  if rg -q 'rollback|UPDATE_IN_PROGRESS|checkpoint|restart-self' "$f" 2>/dev/null; then
    echo "--- $f"
    wc -l "$f"
    sed -n '1,240p' "$f"
  fi
done

echo
echo "== read-only semantic probe of run_deploy allowlist and split behavior =="
python3 - <<'PY'
ALLOWED_COMMANDS = {
    "install-ollama", "install-exo", "install-llama-cpp",
    "install-llama-cpp --cuda", "install-vllm", "install-rknpu",
    "update-worker", "status", "checkpoint", "rollback",
    "restart-self", "health-check",
}
commands = ["rollback", "rollback abc", "rollback abc def", "install-llama-cpp --cuda"]
for command in commands:
    print(command, "-- accepted:", command in ALLOWED_COMMANDS, "args:", command.split())
PY

Repository: jaylfc/taOS

Length of output: 7645


Move rollback allowance and tag argument handling behind the deploy helper contract.

rollback_to_checkpoint() can pass checkpoint_tag, but run_deploy() only allows the exact string "rollback" and will reject "rollback <tag>". If run_deploy() is intended for controller-invoked rollbacks with a specific tag, add the tag argument path into the helper path; otherwise ensure the allowlist is stable for bare self-update rollbacks and explicit manual tags use the dedicated scripts/rollback.sh.

🤖 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/deploy.py` around lines 33 - 37, Update the deploy helper
contract between rollback_to_checkpoint() and run_deploy() so checkpoint_tag
values are handled consistently: either allow and parse the tagged “rollback
<tag>” form in run_deploy() for controller-invoked rollbacks, or keep the
allowlist restricted to bare “rollback” and route explicit manual tags through
scripts/rollback.sh. Preserve bare self-update rollback behavior and ensure
unsupported command forms remain rejected.

Comment on lines +243 to +255
rc, _ = await _run_git(
["fetch", "--quiet", "origin", target_ref],
timeout=120,
)
if rc != 0:
return {
"ok": False,
"output": f"git fetch origin {target_ref} failed",
"exit_code": rc,
}
branch = f"origin/{target_ref}"

rc, out = await _run_git(["checkout", "--quiet", branch])

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Git-option injection via target_ref. A ref beginning with - flows from the update-trigger file into git argument positions that lack a -- separator, where git treats it as an option (--upload-pack=<cmd> is a command-execution vector).

  • tinyagentos/worker/self_update.py#L243-L255: add -- before target_ref in the plain-branch git fetch, and terminate the git checkout argument list.
  • tinyagentos/worker/agent.py#L768-L793: validate target_ref against a conservative ref pattern (and reject leading -) before invoking run_full_update, deleting the trigger on rejection.
📍 Affects 2 files
  • tinyagentos/worker/self_update.py#L243-L255 (this comment)
  • tinyagentos/worker/agent.py#L768-L793
🤖 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/self_update.py` around lines 243 - 255, The plain-branch
update path must prevent git option injection from target_ref. In
tinyagentos/worker/self_update.py lines 243-255, add an argument terminator
before target_ref in git fetch and terminate the git checkout arguments; in
tinyagentos/worker/agent.py lines 768-793, validate target_ref against a
conservative ref pattern that rejects leading hyphens before run_full_update,
and delete the update trigger when validation fails.

Comment on lines +262 to +272
if "/" in target_ref:
local_branch = target_ref.split("/", 1)[1]
await _run_git(
["branch", "-f", local_branch, branch],
timeout=30,
)
elif target_ref and "/" not in target_ref:
await _run_git(
["branch", "-f", target_ref, branch],
timeout=30,
)

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

Redundant branch and still-ignored branch -f return code.

The elif condition's "/" not in target_ref is always true given the preceding if, so the two arms differ only in the local branch name. Both also discard the result: a failed fast-forward leaves a detached HEAD while pull_update returns ok: True.

♻️ Proposed fix
-    if "/" in target_ref:
-        local_branch = target_ref.split("/", 1)[1]
-        await _run_git(
-            ["branch", "-f", local_branch, branch],
-            timeout=30,
-        )
-    elif target_ref and "/" not in target_ref:
-        await _run_git(
-            ["branch", "-f", target_ref, branch],
-            timeout=30,
-        )
+    local_branch = target_ref.split("/", 1)[1] if "/" in target_ref else target_ref
+    if local_branch:
+        rc_b, out_b = await _run_git(["branch", "-f", local_branch, branch], timeout=30)
+        if rc_b != 0:
+            return {"ok": False, "output": f"branch -f {local_branch} failed: {out_b}", "exit_code": rc_b}
🤖 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/self_update.py` around lines 262 - 272, Consolidate the
target_ref handling in the update flow around _run_git: derive the branch name
by removing the remote prefix when target_ref contains "/", otherwise use
target_ref directly, then perform one branch -f operation. Capture and validate
its return result, propagating failure so pull_update does not return ok: True
while HEAD remains detached.

Comment on lines +617 to +644
# Check the heartbeat response body for drain_complete.
# We re-post a lightweight request so we can read the response
# payload (heartbeat() only returns the status code).
try:
path = "/api/cluster/heartbeat"
payload = _json.dumps({
"name": name,
"load": 0.0,
"status": "draining",
"drain_reason": "update",
}).encode()
headers = sign_request_headers(key, name, "POST", path, payload) if key else {}
headers["content-type"] = "application/json"

async with httpx.AsyncClient(timeout=5) as client:
resp = await client.post(
f"{controller.rstrip('/')}{path}",
content=payload,
headers=headers,
)
if resp.status_code == 200:
body = resp.json()
if body.get("drain_complete"):
logger.info(
"self-update: drain confirmed complete by controller "
"(%.0fs elapsed)", elapsed,
)
return 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.

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

Second heartbeat POST per tick, and it zeroes the reported load.

agent.heartbeat() at Line 603 already sent a full heartbeat whose body carries drain_complete; this re-post doubles controller traffic every 5s and hardcodes "load": 0.0, which ClusterManager.heartbeat() assigns unconditionally — overwriting the worker's real load for the whole drain window. Have heartbeat() surface the parsed body (or the last response) and read drain_complete from it; at minimum omit load here.

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

In `@tinyagentos/worker/self_update.py` around lines 617 - 644, Update the
self-update drain polling flow around agent.heartbeat() to reuse the existing
heartbeat response/body and read its parsed drain_complete value, removing the
second HTTP POST and duplicate request construction. Ensure the heartbeat
payload continues reporting the worker’s real load rather than overwriting it
with 0.0, while preserving the existing successful drain-confirmation behavior.

@jaylfc

jaylfc commented Jul 28, 2026

Copy link
Copy Markdown
Owner

Confirming your fix resolved it, and that my earlier read was right rather than a flake: f9678461 sets hmac_worker_name in the update-outcome test patch, so the test now authenticates and the endpoint is reached. That matches what the assert 403 == 404 case implied, which is that the refusal happened before the worker lookup rather than there being four separate bugs. All eight shards are green and all four required checks pass.

Not merging it on the sweep, and the reason is scope rather than anything wrong with it. This is +1815/-7 across 7 files implementing worker self-update, restart, health-check and rollback. No bot has commented on the current head, so there is no review covering this tree, and green CI is not a review. Self-update and rollback logic is close enough to "can take a worker down" that I want it read properly rather than merged by a 4-hourly automated pass.

So it is blocked on review, not on CI. Nothing for you to do unless you want to flag anything specific you would like looked at first.

@hognek
hognek force-pushed the feat/worker-self-update-rollback branch 2 times, most recently from 43886e6 to f967846 Compare July 30, 2026 12:00
hognek added a commit to hognek/tinyagentos that referenced this pull request Jul 30, 2026
…ploy_backend

1. Worker-name authorization: deploy_backend now calls require_worker_hmac
   and verifies the authenticated worker matches the path name. Previously
   the endpoint had no auth at all - any caller could trigger deploys on
   any worker. Now only the paired worker can deploy on itself
   (CodeRabbit, PR jaylfc#1910).

2. Concurrency guard: added per-worker asyncio.Lock
   (_worker_deploy_locks) to both deploy_backend and
   _do_single_worker_update. Prevents concurrent API calls from
   double-installing or double-draining the same worker.
hognek added 4 commits July 31, 2026 11:09
…orchestrator (jaylfc#890 C3)

BLOCKER 1: _detached_restart_worker() uses at(1) → systemd-run --scope
→ --no-block fallback chain so the restart survives systemd
KillMode=control-group teardown. Previously the deploy-helper
(child of the worker's cgroup) was SIGTERM'd mid-restart,
permanently bricking the worker.

BLOCKER 2: Wire _check_update_trigger() into the agent heartbeat
loop (reads update-trigger.json), and post_update_startup() into
the post-registration path (reads update-in-progress.json marker).
The orchestrator now has real callers.

Also: drain_complete in heartbeat response, update-outcome HMAC
endpoint, C3 deploy-helper subcommands (checkpoint/rollback/
restart-self/health-check), and deploy.py allowlist update.

Tests: 42 pass (22 self_update + 20 cluster unit). App-fixture
endpoint tests deferred to CI (pre-existing conftest hang).
The HMAC gate only proves the caller is a paired worker, not which
worker. Without this check, worker A could spoof an outcome report
for worker B. Now matches the pattern used by incus-enroll and
heartbeat endpoints.

QA-Impacted: TestUpdateOutcomeEndpoint tests may need hmac_worker_name
set on request.state — CI will catch.
The route-level name cross-check (report_update_outcome line 1500)
requires request.state.hmac_worker_name to match the path {name}
parameter. The previous side_effect=lambda r: None left it unset,
causing all 4 TestUpdateOutcomeEndpoint tests to return 403 instead
of their expected status codes (200/404/400).
…ploy_backend

1. Worker-name authorization: deploy_backend now calls require_worker_hmac
   and verifies the authenticated worker matches the path name. Previously
   the endpoint had no auth at all - any caller could trigger deploys on
   any worker. Now only the paired worker can deploy on itself
   (CodeRabbit, PR jaylfc#1910).

2. Concurrency guard: added per-worker asyncio.Lock
   (_worker_deploy_locks) to both deploy_backend and
   _do_single_worker_update. Prevents concurrent API calls from
   double-installing or double-draining the same worker.
@hognek
hognek force-pushed the feat/worker-self-update-rollback branch from 0d6e99b to 2bff5d0 Compare July 31, 2026 09:09

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

♻️ Duplicate comments (5)
scripts/taos-deploy-helper.sh (2)

272-281: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Bound the tag-collision retry loop.

git tag can fail for reasons other than an existing tag, for example a stale index.lock, a read-only filesystem, or a permission error. In that case the while loop never terminates and cmd_checkpoint hangs, which blocks phase 1 of run_full_update. Add an attempt limit. The same diff also resolves the ShellCheck SC2155 warning on Line 273.

🔒 Proposed fix
-    local tag="taos-worker-pre-update-$(date -u +%Y%m%d-%H%M%S)"
+    local tag
+    tag="taos-worker-pre-update-$(date -u +%Y%m%d-%H%M%S)"
     git -C "$repo_dir" tag "$tag" HEAD 2>/dev/null || {
         # Tag already exists — add a counter suffix
         local suffix=1
         while ! git -C "$repo_dir" tag "${tag}-${suffix}" HEAD 2>/dev/null; do
+            if (( suffix >= 20 )); then
+                die "failed to create checkpoint tag after ${suffix} attempts"
+            fi
             ((suffix++))
         done
         tag="${tag}-${suffix}"
     }
🤖 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 `@scripts/taos-deploy-helper.sh` around lines 272 - 281, Update the tag
creation logic in cmd_checkpoint to separate the tag variable assignment from
command substitution to resolve ShellCheck SC2155, and bound the suffix retry
loop with a finite attempt limit. Stop retrying when the limit is reached and
propagate a clear failure instead of allowing run_full_update to hang on
persistent git errors.

Source: Linters/SAST tools


320-330: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

systemd-run --scope --no-block does not detach the restart.

With --scope, systemd-run stays the parent of the command and waits for it to finish. --no-block does not change that. This branch therefore runs systemctl restart inside the caller's cgroup, which is the exact failure this helper tries to avoid. Use the default transient service mode (drop --scope) so the restart runs under systemd and survives the worker cgroup teardown.

The PR objectives state that restart and rollback now use a single systemctl restart operation. The code still contains the three-tier at(1) / systemd-run --scope / systemctl --no-block fallback chain. Confirm which behavior is intended.

♻️ Proposed fix
     if command -v systemd-run >/dev/null 2>&1; then
-        if systemd-run --scope --no-block systemctl restart tinyagentos-worker.service 2>/dev/null; then
+        if systemd-run --no-block --unit="taos-worker-restart-$$" \
+                --collect systemctl restart tinyagentos-worker.service 2>/dev/null; then
             log "worker restart dispatched via systemd-run"
             return 0
         fi
-        if systemd-run --scope --no-block systemctl restart taos-worker.service 2>/dev/null; then
+        if systemd-run --no-block --unit="taos-worker-restart-alt-$$" \
+                --collect systemctl restart taos-worker.service 2>/dev/null; then
             log "worker restart dispatched via systemd-run (taos-worker)"
             return 0
         fi
     fi
🤖 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 `@scripts/taos-deploy-helper.sh` around lines 320 - 330, Update the systemd-run
fallback in the worker restart flow to use transient service mode by removing
--scope while retaining --no-block, and apply the same change to both
tinyagentos-worker.service and taos-worker.service branches. Align the
surrounding restart logic with the stated single systemctl restart approach,
removing the obsolete three-tier at/systemd-run --scope/systemctl --no-block
fallback behavior as appropriate.
tinyagentos/worker/self_update.py (3)

262-272: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

The elif condition is always true, and both branch -f results are discarded.

The if at Line 262 already handles every ref that contains /, so "/" not in target_ref at Line 268 is redundant. The two arms differ only in the local branch name.

Neither call checks the exit code. If the fast-forward fails, pull_update still returns {"ok": True} while HEAD stays detached, and the next update repeats the detached checkout. This was raised on a previous commit.

♻️ Proposed fix
-    if "/" in target_ref:
-        local_branch = target_ref.split("/", 1)[1]
-        await _run_git(
-            ["branch", "-f", local_branch, branch],
-            timeout=30,
-        )
-    elif target_ref and "/" not in target_ref:
-        await _run_git(
-            ["branch", "-f", target_ref, branch],
-            timeout=30,
-        )
+    local_branch = target_ref.split("/", 1)[1] if "/" in target_ref else target_ref
+    if local_branch:
+        rc_b, out_b = await _run_git(["branch", "-f", local_branch, branch], timeout=30)
+        if rc_b != 0:
+            return {
+                "ok": False,
+                "output": f"branch -f {local_branch} failed: {out_b}",
+                "exit_code": rc_b,
+            }
🤖 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/self_update.py` around lines 262 - 272, Update the
target-branch handling in pull_update to use one branch-name selection path
instead of the redundant elif, then check and propagate the result of the
_run_git branch -f operation. Ensure a failed branch update prevents pull_update
from returning success and preserves the existing failure behavior, avoiding a
successful response while HEAD remains detached.

243-255: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

target_ref still reaches git argument positions without a -- terminator.

A ref that begins with - is parsed by git as an option. git fetch origin --upload-pack=<cmd> executes a command. Two positions are affected:

  • Line 244: ["fetch", "--quiet", "origin", target_ref].
  • Line 255: ["checkout", "--quiet", branch], where branch == target_ref on the origin/<branch> path.

The comment on Lines 240-242 explains why -- is omitted for fetch. If that constraint holds, validate target_ref against a conservative ref pattern at the entry point and reject a leading -. This was raised on a previous commit.

🔒️ Proposed guard
+import re
+
+_SAFE_REF = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]*$")
+
 async def pull_update(target_ref: str) -> dict:
     ...
     repo = _repo_dir()
+    if not _SAFE_REF.match(target_ref):
+        return {
+            "ok": False,
+            "output": f"refusing unsafe target_ref: {target_ref!r}",
+            "exit_code": -1,
+        }
     branch = target_ref
🤖 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/self_update.py` around lines 243 - 255, Validate
target_ref at the self-update entry point before it reaches _run_git, rejecting
refs that begin with "-" and enforcing a conservative valid-ref pattern. Apply
this guard to both the fetch path using target_ref and the checkout path using
origin/{target_ref}, while preserving the existing fetch argument structure and
failure response conventions.

617-644: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

The second heartbeat POST is still present and still reports load: 0.0.

agent.heartbeat() at Line 603 already sent a heartbeat whose response body carries drain_complete. This block re-signs and re-sends a second heartbeat every 5s only to read that field. The payload hardcodes "load": 0.0, and ClusterManager.heartbeat() assigns load unconditionally, so the worker's real load is overwritten for the whole drain window. This was raised on a previous commit.

Have heartbeat() return the parsed body, then read drain_complete from it. If a separate request stays, omit load.

Related: elapsed advances by interval only and ignores request latency, so the real wait exceeds timeout. Use a monotonic deadline instead.

♻️ Deadline accounting
-    elapsed = 0.0
     interval = 5.0
+    deadline = asyncio.get_running_loop().time() + timeout
     saw_ok = False
-    while elapsed < timeout:
+    while asyncio.get_running_loop().time() < deadline:
         await asyncio.sleep(interval)
-        elapsed += interval
🤖 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/self_update.py` around lines 617 - 644, Update the
drain-wait logic in the self-update flow to use the response body returned by
agent.heartbeat() and read drain_complete without issuing the duplicate
heartbeat POST or overwriting load with 0.0. Replace elapsed-plus-interval
accounting with a monotonic deadline based on the timeout, accounting for
heartbeat/request latency while preserving the existing completion and timeout
behavior.
🧹 Nitpick comments (7)
scripts/taos-deploy-helper.sh (1)

394-396: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Brace the variable so the extras suffix is unambiguous.

ShellCheck reports SC1087 on Line 394. The expansion "$repo_dir[worker]" produces the intended pip target, but the syntax reads as an array index. Use "${repo_dir}[worker]" to make the intent explicit and clear the warning.

♻️ Proposed fix
-                "$venv/bin/pip" install -q -e "$repo_dir[worker]" 2>/dev/null || \
+                "$venv/bin/pip" install -q -e "${repo_dir}[worker]" 2>/dev/null || \
🤖 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 `@scripts/taos-deploy-helper.sh` around lines 394 - 396, Update the pip install
target in the deployment script to use braced expansion, changing the repo_dir
reference in the worker extras expression to the unambiguous ${repo_dir}[worker]
form while preserving the existing fallback chain.

Source: Linters/SAST tools

tests/test_worker_self_update.py (2)

309-322: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Patch the sleep call on the module under test, not on asyncio.

monkeypatch.setattr(asyncio, "sleep", ...) replaces the attribute on the stdlib module for the duration of the test. Every awaiting coroutine in the process observes the replacement, including pytest plugins and any background task. Patch su.asyncio.sleep is the same object, so scope the patch to the awaited call instead. patch.object on the module reference under test states the intent more precisely and keeps the blast radius small.

♻️ Proposed change
-        monkeypatch.setattr(asyncio, "sleep", short_sleep)
+        monkeypatch.setattr(su.asyncio, "sleep", short_sleep)

Also applies to: 391-391

🤖 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_worker_self_update.py` around lines 309 - 322, Update the sleep
monkeypatch in the test setup to target the module-under-test reference via
patch.object on su.asyncio, rather than mutating the global asyncio module
directly. Apply the same scoped change to the additional occurrence near the
later test line, preserving short_sleep’s no-op behavior.

458-500: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Add coverage for signal_update_outcome and _wait_for_drain.

The rollback test asserts only that signal_update_outcome was called with outcome="rollback". The real function is stubbed in every test, so the HMAC signing of the outcome payload has no coverage in this file. That signing is the authentication boundary for the controller endpoint added by this PR. A signature-format regression would pass all current tests and fail only in production with HTTP 403.

_wait_for_drain is also untested. run_full_update is exercised with graceful=False at Line 329 and with the default at Line 357, where pull_update fails before the drain wait is reached. Add a test that drives _wait_for_drain to both the completed and the timeout outcome.

Do you want me to draft these 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_worker_self_update.py` around lines 458 - 500, Add focused tests
in tests/test_worker_self_update.py for signal_update_outcome that exercise the
real implementation, verify the outcome payload is HMAC-signed with the agent
signing key, and validate the request signature/format sent to the controller.
Add coverage for _wait_for_drain covering both successful completion and
timeout, using mocked drain/status behavior and a short timeout without stubbing
the function under test. Keep existing rollback tests’ stubs unchanged.
tinyagentos/routes/cluster.py (2)

1513-1520: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

body.name is accepted but never validated or used.

The handler authorises on the path name and logs the path name. body.name is never compared to name and never read. A worker can post {"name": "other-worker", ...} and the request succeeds under its own identity. Either drop the field from UpdateOutcomeBody or reject a mismatch, so the payload cannot suggest an outcome for another worker in stored logs or future consumers.

♻️ Proposed check
     if getattr(request.state, "hmac_worker_name", None) != name:
         return JSONResponse(
             {"error": "Worker name in header does not match path"},
             status_code=403,
         )
+    if body.name != name:
+        return JSONResponse(
+            {"error": "Worker name in body does not match path"},
+            status_code=400,
+        )

Also applies to: 1541-1545

🤖 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 1513 - 1520, Update
UpdateOutcomeBody and the associated update-outcome handler so the payload
cannot carry an unrelated worker identity: either remove the unused name field
from UpdateOutcomeBody, or validate that body.name matches the path name and
reject mismatches before processing or logging the outcome. Keep authorization
and logging tied to the path name.

920-923: 🩺 Stability & Availability | 🔵 Trivial

The lock only serialises deploys inside one controller process.

_worker_deploy_locks is process-local. If the controller ever runs with more than one uvicorn worker process or more than one replica, two concurrent deploy requests can still reach the same worker. If a single-process deployment is guaranteed, state that in the comment. Otherwise, add an idempotency guard on the worker side (for example, reject /api/worker/deploy while an install is already running).

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

In `@tinyagentos/routes/cluster.py` around lines 920 - 923, Address the
process-local scope of _worker_deploy_locks: either explicitly document and
enforce that the controller runs as a single process, or add a worker-side
idempotency guard in the /api/worker/deploy handling path that rejects
deployments while an install is active. Ensure concurrent requests from separate
controller processes cannot overlap on the same worker.
tinyagentos/worker/self_update.py (2)

417-431: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Add a bounded retry for the outcome report.

This POST is the only signal the controller receives about the update result. One transient network error, or a controller restart during the worker restart window, drops the report permanently. On the rollback path, the operator then gets no worker.update-rollback notification.

post_update_startup also clears the marker right after this call, so nothing retries later. Add two or three attempts with backoff, and treat a non-200 status the same as a connection failure.

🤖 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/self_update.py` around lines 417 - 431, Update the
outcome-reporting POST in the self-update flow around post_update_startup to
make two or three bounded attempts with backoff, retrying both request
exceptions and non-200 responses. Return the successful status when received,
and only log the failure and return 0 after all attempts are exhausted.

189-199: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Parse the checkpoint tag from a labelled line, not from the last output line.

_run_helper merges stderr into stdout. Any trailing warning from git or from the helper becomes the last non-[taos-deploy] line, so tag takes that value. The wrong tag then flows into rollback_to_checkpoint and the rollback targets the wrong ref, or fails.

Emit a labelled line from taos-deploy-helper.sh (for example CHECKPOINT_TAG=<tag>) and match that prefix here. Also treat an empty tag as a checkpoint failure, because rollback without a tag depends on the manifest file being correct.

🤖 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/self_update.py` around lines 189 - 199, Update the
checkpoint parsing in the self-update flow to extract the tag only from the
labelled CHECKPOINT_TAG output emitted by taos-deploy-helper.sh, ignoring
unrelated merged stderr or warning lines. Ensure the helper emits that label
when creating a checkpoint, and treat a missing or empty parsed tag as
checkpoint failure before passing it to rollback_to_checkpoint.
🤖 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 `@scripts/taos-deploy-helper.sh`:
- Line 356: Update the checkpoint_tag extraction commands near checkpoint
manifest handling to pass manifest_file through Python’s command-line arguments
and read it via sys.argv, rather than interpolating it into the Python source.
Apply the same change to both affected lines while preserving the existing JSON
field lookup and fallback behavior.
- Around line 379-382: Update cmd_rollback to read the recorded git_branch from
the manifest and restore that branch at the checkpoint commit instead of
checking out checkpoint_tag directly. Ensure the repository ends on the recorded
branch, preserving the existing verification log and failure handling.
- Around line 437-453: Update the port detection logic in cmd_health_check to
avoid the Linux-only netstat -p invocation on macOS. Use a cross-platform
listening-port check, while preserving the existing ss/netstat fallback
structure and success/failure reporting so healthy macOS workers do not trigger
rollback.
- Around line 312-318: Update the at(1) branch in _detached_restart_worker to
verify that the atd daemon is active before treating a successfully queued job
as a valid restart. Only log success and return 0 when both atd is running and
the at command succeeds; otherwise continue to the existing fallback restart
paths.

In `@tinyagentos/routes/cluster.py`:
- Around line 1532-1535: Update the _HMACError handler in worker_heartbeat’s
surrounding request flow to return the exception’s structured response via
exc.response instead of constructing a generic 403 JSONResponse. Preserve
require_worker_hmac validation and ensure the worker can receive the original
status and error details.

In `@tinyagentos/worker/self_update.py`:
- Around line 116-133: Handle subprocess timeouts consistently across both
update sites: in tinyagentos/worker/self_update.py lines 116-133, update
_run_git to catch asyncio.TimeoutError around proc.communicate(), kill the
process, and return a non-zero status with a timeout message; in
tinyagentos/worker/self_update.py lines 291-322, apply the same timeout handling
to both wait_for calls in update_dependencies, killing each process and
returning {"ok": False, ...} so callers can continue rollback and completion
handling.
- Around line 517-531: Update both abort paths in the self-update flow around
pull failure and dependency failure to clear self_update_status after
notify_drain_complete(), allowing traffic to be re-routed. In the Phase 6
dependency failure path, restore the virtualenv/dependency environment before
calling rollback_to_checkpoint(checkpoint_tag), while preserving the existing
rollback and return behavior.
- Around line 682-691: Update the WorkerAgent.run flow so post_update_startup()
is scheduled as a background task rather than awaited inline, allowing the
worker loop to register and send heartbeats immediately. Preserve the existing
startup health-check behavior while ensuring the task is tracked or safely
handled during shutdown.
- Around line 693-720: The failed health-check path should signal the rollback
outcome and clear the update marker before invoking rollback_to_checkpoint(),
then terminate or delegate in a way that preserves the current post-rollback
process behavior. Add an attempt counter to update-in-progress.json, increment
it across restarts, and stop retrying after a fixed cap of failed post-restart
health checks.

---

Duplicate comments:
In `@scripts/taos-deploy-helper.sh`:
- Around line 272-281: Update the tag creation logic in cmd_checkpoint to
separate the tag variable assignment from command substitution to resolve
ShellCheck SC2155, and bound the suffix retry loop with a finite attempt limit.
Stop retrying when the limit is reached and propagate a clear failure instead of
allowing run_full_update to hang on persistent git errors.
- Around line 320-330: Update the systemd-run fallback in the worker restart
flow to use transient service mode by removing --scope while retaining
--no-block, and apply the same change to both tinyagentos-worker.service and
taos-worker.service branches. Align the surrounding restart logic with the
stated single systemctl restart approach, removing the obsolete three-tier
at/systemd-run --scope/systemctl --no-block fallback behavior as appropriate.

In `@tinyagentos/worker/self_update.py`:
- Around line 262-272: Update the target-branch handling in pull_update to use
one branch-name selection path instead of the redundant elif, then check and
propagate the result of the _run_git branch -f operation. Ensure a failed branch
update prevents pull_update from returning success and preserves the existing
failure behavior, avoiding a successful response while HEAD remains detached.
- Around line 243-255: Validate target_ref at the self-update entry point before
it reaches _run_git, rejecting refs that begin with "-" and enforcing a
conservative valid-ref pattern. Apply this guard to both the fetch path using
target_ref and the checkout path using origin/{target_ref}, while preserving the
existing fetch argument structure and failure response conventions.
- Around line 617-644: Update the drain-wait logic in the self-update flow to
use the response body returned by agent.heartbeat() and read drain_complete
without issuing the duplicate heartbeat POST or overwriting load with 0.0.
Replace elapsed-plus-interval accounting with a monotonic deadline based on the
timeout, accounting for heartbeat/request latency while preserving the existing
completion and timeout behavior.

---

Nitpick comments:
In `@scripts/taos-deploy-helper.sh`:
- Around line 394-396: Update the pip install target in the deployment script to
use braced expansion, changing the repo_dir reference in the worker extras
expression to the unambiguous ${repo_dir}[worker] form while preserving the
existing fallback chain.

In `@tests/test_worker_self_update.py`:
- Around line 309-322: Update the sleep monkeypatch in the test setup to target
the module-under-test reference via patch.object on su.asyncio, rather than
mutating the global asyncio module directly. Apply the same scoped change to the
additional occurrence near the later test line, preserving short_sleep’s no-op
behavior.
- Around line 458-500: Add focused tests in tests/test_worker_self_update.py for
signal_update_outcome that exercise the real implementation, verify the outcome
payload is HMAC-signed with the agent signing key, and validate the request
signature/format sent to the controller. Add coverage for _wait_for_drain
covering both successful completion and timeout, using mocked drain/status
behavior and a short timeout without stubbing the function under test. Keep
existing rollback tests’ stubs unchanged.

In `@tinyagentos/routes/cluster.py`:
- Around line 1513-1520: Update UpdateOutcomeBody and the associated
update-outcome handler so the payload cannot carry an unrelated worker identity:
either remove the unused name field from UpdateOutcomeBody, or validate that
body.name matches the path name and reject mismatches before processing or
logging the outcome. Keep authorization and logging tied to the path name.
- Around line 920-923: Address the process-local scope of _worker_deploy_locks:
either explicitly document and enforce that the controller runs as a single
process, or add a worker-side idempotency guard in the /api/worker/deploy
handling path that rejects deployments while an install is active. Ensure
concurrent requests from separate controller processes cannot overlap on the
same worker.

In `@tinyagentos/worker/self_update.py`:
- Around line 417-431: Update the outcome-reporting POST in the self-update flow
around post_update_startup to make two or three bounded attempts with backoff,
retrying both request exceptions and non-200 responses. Return the successful
status when received, and only log the failure and return 0 after all attempts
are exhausted.
- Around line 189-199: Update the checkpoint parsing in the self-update flow to
extract the tag only from the labelled CHECKPOINT_TAG output emitted by
taos-deploy-helper.sh, ignoring unrelated merged stderr or warning lines. Ensure
the helper emits that label when creating a checkpoint, and treat a missing or
empty parsed tag as checkpoint failure before passing it to
rollback_to_checkpoint.
🪄 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: bef99d7e-ef83-4b44-86bd-c1d840ca46e6

📥 Commits

Reviewing files that changed from the base of the PR and between f967846 and 2bff5d0.

📒 Files selected for processing (7)
  • scripts/taos-deploy-helper.sh
  • tests/test_cluster.py
  • tests/test_worker_self_update.py
  • tinyagentos/routes/cluster.py
  • tinyagentos/worker/agent.py
  • tinyagentos/worker/deploy.py
  • tinyagentos/worker/self_update.py
🚧 Files skipped from review as they are similar to previous changes (3)
  • tinyagentos/worker/deploy.py
  • tests/test_cluster.py
  • tinyagentos/worker/agent.py

Comment on lines +312 to +318
# 1 — at(1): schedules the restart after the helper exits (cleanest).
if command -v at >/dev/null 2>&1; then
if echo "$restart_cmd" | at now 2>/dev/null; then
log "worker restart scheduled via at(1)"
return 0
fi
fi

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

The at(1) branch reports success even when atd is not running.

echo "$restart_cmd" | at now exits 0 when the job is queued. On many hosts the at binary is installed but the atd daemon is disabled or stopped. The job is then queued and never executed. _detached_restart_worker returns 0, restart_service() reports ok, and run_full_update returns {"ok": True} at phase 9 without a restart. The worker keeps running the old code from memory while the new code sits on disk, and the update marker is never processed, so post_update_startup() cannot detect or roll back the failure.

Check that atd is active before you accept this path.

🔒 Proposed fix
     # 1 — at(1): schedules the restart after the helper exits (cleanest).
-    if command -v at >/dev/null 2>&1; then
+    if command -v at >/dev/null 2>&1 && \
+       (systemctl is-active --quiet atd 2>/dev/null || systemctl is-active --quiet atd.service 2>/dev/null); then
         if echo "$restart_cmd" | at now 2>/dev/null; then
             log "worker restart scheduled via at(1)"
             return 0
         fi
     fi
📝 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
# 1 — at(1): schedules the restart after the helper exits (cleanest).
if command -v at >/dev/null 2>&1; then
if echo "$restart_cmd" | at now 2>/dev/null; then
log "worker restart scheduled via at(1)"
return 0
fi
fi
# 1 — at(1): schedules the restart after the helper exits (cleanest).
if command -v at >/dev/null 2>&1 && \
(systemctl is-active --quiet atd 2>/dev/null || systemctl is-active --quiet atd.service 2>/dev/null); then
if echo "$restart_cmd" | at now 2>/dev/null; then
log "worker restart scheduled via at(1)"
return 0
fi
fi
🤖 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 `@scripts/taos-deploy-helper.sh` around lines 312 - 318, Update the at(1)
branch in _detached_restart_worker to verify that the atd daemon is active
before treating a successfully queued job as a valid restart. Only log success
and return 0 when both atd is running and the at command succeeds; otherwise
continue to the existing fallback restart paths.

if [[ -z "$checkpoint_tag" ]]; then
# Read the tag from the manifest if not provided
if [[ -f "$manifest_file" ]]; then
checkpoint_tag="$(python3 -c "import json; print(json.load(open('$manifest_file')).get('checkpoint_tag',''))" 2>/dev/null || 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.

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Pass the manifest path as an argument instead of interpolating it into the Python source.

$manifest_file comes from TAOS_CHECKPOINT_MANIFEST. Both lines embed that value inside a single-quoted Python string literal. A path that contains a quote or a backslash breaks the literal, and the remainder is executed as Python code. Pass the path through sys.argv instead.

🔒 Proposed fix
-            checkpoint_tag="$(python3 -c "import json; print(json.load(open('$manifest_file')).get('checkpoint_tag',''))" 2>/dev/null || true)"
+            checkpoint_tag="$(python3 -c "import json,sys; print(json.load(open(sys.argv[1])).get('checkpoint_tag',''))" "$manifest_file" 2>/dev/null || true)"
-        pkg_manager="$(python3 -c "import json; print(json.load(open('$manifest_file')).get('package_manager','pip'))" 2>/dev/null || echo "pip")"
+        pkg_manager="$(python3 -c "import json,sys; print(json.load(open(sys.argv[1])).get('package_manager','pip'))" "$manifest_file" 2>/dev/null || echo "pip")"

Also applies to: 387-387

🤖 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 `@scripts/taos-deploy-helper.sh` at line 356, Update the checkpoint_tag
extraction commands near checkpoint manifest handling to pass manifest_file
through Python’s command-line arguments and read it via sys.argv, rather than
interpolating it into the Python source. Apply the same change to both affected
lines while preserving the existing JSON field lookup and fallback behavior.

Comment on lines +379 to +382
git -C "$repo_dir" checkout --quiet "$checkpoint_tag" || {
die "git checkout $checkpoint_tag failed"
}
log "restored code to $checkpoint_tag ($(git -C "$repo_dir" rev-parse --short HEAD))"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Rollback leaves the repository in detached HEAD, which breaks update-worker.

git checkout --quiet "$checkpoint_tag" checks out a tag, so HEAD becomes detached. cmd_update_worker (Line 217) then runs git pull --ff-based origin "$BRANCH"git pull --ff-only origin "$BRANCH" — which fails in a detached HEAD state. Any later deploy through this helper therefore fails until an operator checks out a branch manually.

cmd_checkpoint already records git_branch in the manifest, but cmd_rollback never reads it. Restore the recorded branch to the checkpoint commit instead.

♻️ Proposed fix
+    local git_branch=""
+    if [[ -f "$manifest_file" ]]; then
+        git_branch="$(python3 -c "import json,sys; print(json.load(open(sys.argv[1])).get('git_branch',''))" "$manifest_file" 2>/dev/null || true)"
+    fi
+
     git -C "$repo_dir" checkout --quiet "$checkpoint_tag" || {
         die "git checkout $checkpoint_tag failed"
     }
+    if [[ -n "$git_branch" && "$git_branch" != "detached" && "$git_branch" != "HEAD" ]]; then
+        git -C "$repo_dir" checkout --quiet -B "$git_branch" "$checkpoint_tag" \
+            || log "WARN: could not restore branch $git_branch — repo left detached at $checkpoint_tag"
+    fi
     log "restored code to $checkpoint_tag ($(git -C "$repo_dir" rev-parse --short HEAD))"
📝 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
git -C "$repo_dir" checkout --quiet "$checkpoint_tag" || {
die "git checkout $checkpoint_tag failed"
}
log "restored code to $checkpoint_tag ($(git -C "$repo_dir" rev-parse --short HEAD))"
local git_branch=""
if [[ -f "$manifest_file" ]]; then
git_branch="$(python3 -c "import json,sys; print(json.load(open(sys.argv[1])).get('git_branch',''))" "$manifest_file" 2>/dev/null || true)"
fi
git -C "$repo_dir" checkout --quiet "$checkpoint_tag" || {
die "git checkout $checkpoint_tag failed"
}
if [[ -n "$git_branch" && "$git_branch" != "detached" && "$git_branch" != "HEAD" ]]; then
git -C "$repo_dir" checkout --quiet -B "$git_branch" "$checkpoint_tag" \
|| log "WARN: could not restore branch $git_branch — repo left detached at $checkpoint_tag"
fi
log "restored code to $checkpoint_tag ($(git -C "$repo_dir" rev-parse --short HEAD))"
🤖 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 `@scripts/taos-deploy-helper.sh` around lines 379 - 382, Update cmd_rollback to
read the recorded git_branch from the manifest and restore that branch at the
checkpoint commit instead of checking out checkpoint_tag directly. Ensure the
repository ends on the recorded branch, preserving the existing verification log
and failure handling.

Comment on lines +437 to +453
# 2. Check worker port is listening (default 9898)
local port="${TAOS_WORKER_PORT:-9898}"
if command -v ss >/dev/null 2>&1; then
if ss -tlnp 2>/dev/null | grep -q ":$port "; then
log "health-check: port $port is listening"
else
ok=false
failures+=("port $port not listening")
fi
elif command -v netstat >/dev/null 2>&1; then
if netstat -tlnp 2>/dev/null | grep -q ":$port "; then
log "health-check: port $port is listening"
else
ok=false
failures+=("port $port not listening")
fi
fi

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

The port check fails on macOS and triggers a false rollback.

cmd_health_check supports macOS: Lines 426-434 fall back to launchctl. On macOS, ss is absent and BSD netstat does not accept -p in the Linux sense, so netstat -tlnp exits with a usage error. grep -q then finds no match, ok becomes false, and the command exits 1. post_update_startup() treats that as a failed health check and rolls back a healthy worker.

Detect the port with a tool that works on both platforms.

🔒 Proposed fix
     local port="${TAOS_WORKER_PORT:-9898}"
     if command -v ss >/dev/null 2>&1; then
         if ss -tlnp 2>/dev/null | grep -q ":$port "; then
             log "health-check: port $port is listening"
         else
             ok=false
             failures+=("port $port not listening")
         fi
+    elif command -v lsof >/dev/null 2>&1; then
+        if lsof -nP -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1; then
+            log "health-check: port $port is listening"
+        else
+            ok=false
+            failures+=("port $port not listening")
+        fi
     elif command -v netstat >/dev/null 2>&1; then
-        if netstat -tlnp 2>/dev/null | grep -q ":$port "; then
+        if netstat -an 2>/dev/null | grep -qE "[.:]$port[[:space:]].*LISTEN"; then
             log "health-check: port $port is listening"
         else
             ok=false
             failures+=("port $port not listening")
         fi
+    else
+        log "health-check: no tool available to check port $port — skipping"
     fi
📝 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
# 2. Check worker port is listening (default 9898)
local port="${TAOS_WORKER_PORT:-9898}"
if command -v ss >/dev/null 2>&1; then
if ss -tlnp 2>/dev/null | grep -q ":$port "; then
log "health-check: port $port is listening"
else
ok=false
failures+=("port $port not listening")
fi
elif command -v netstat >/dev/null 2>&1; then
if netstat -tlnp 2>/dev/null | grep -q ":$port "; then
log "health-check: port $port is listening"
else
ok=false
failures+=("port $port not listening")
fi
fi
# 2. Check worker port is listening (default 9898)
local port="${TAOS_WORKER_PORT:-9898}"
if command -v ss >/dev/null 2>&1; then
if ss -tlnp 2>/dev/null | grep -q ":$port "; then
log "health-check: port $port is listening"
else
ok=false
failures+=("port $port not listening")
fi
elif command -v lsof >/dev/null 2>&1; then
if lsof -nP -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1; then
log "health-check: port $port is listening"
else
ok=false
failures+=("port $port not listening")
fi
elif command -v netstat >/dev/null 2>&1; then
if netstat -an 2>/dev/null | grep -qE "[.:]$port[[:space:]].*LISTEN"; then
log "health-check: port $port is listening"
else
ok=false
failures+=("port $port not listening")
fi
else
log "health-check: no tool available to check port $port — skipping"
fi
🤖 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 `@scripts/taos-deploy-helper.sh` around lines 437 - 453, Update the port
detection logic in cmd_health_check to avoid the Linux-only netstat -p
invocation on macOS. Use a cross-platform listening-port check, while preserving
the existing ss/netstat fallback structure and success/failure reporting so
healthy macOS workers do not trigger rollback.

Comment on lines +1532 to +1535
try:
await require_worker_hmac(request)
except _HMACError:
return JSONResponse({"error": "hmac verification failed"}, status_code=403)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Return the HMAC error response instead of a flat 403.

require_worker_hmac raises _HMACError with a structured response and a 401 status for unpaired workers, stale timestamps, and bad signatures. This handler discards it and always returns 403 with a generic message. worker_heartbeat (Line 501) and deploy_backend (Line 944) return exc.response. Match them so the worker can distinguish a pairing problem from a signature problem.

🔧 Proposed fix
     try:
         await require_worker_hmac(request)
-    except _HMACError:
-        return JSONResponse({"error": "hmac verification failed"}, status_code=403)
+    except _HMACError as exc:
+        return exc.response
📝 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
try:
await require_worker_hmac(request)
except _HMACError:
return JSONResponse({"error": "hmac verification failed"}, status_code=403)
try:
await require_worker_hmac(request)
except _HMACError as exc:
return exc.response
🤖 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 1532 - 1535, Update the
_HMACError handler in worker_heartbeat’s surrounding request flow to return the
exception’s structured response via exc.response instead of constructing a
generic 403 JSONResponse. Preserve require_worker_hmac validation and ensure the
worker can receive the original status and error details.

Comment on lines +116 to +133
async def _run_git(
args: list[str],
cwd: Path | None = None,
timeout: float = 120,
) -> tuple[int, str]:
"""Run a git command safely (list of args, no shell).

Returns (returncode, stdout_or_stderr).
"""
repo = cwd or _repo_dir()
proc = await asyncio.create_subprocess_exec(
"git", *args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
cwd=str(repo),
)
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout)
return proc.returncode or 0, (stdout.decode("utf-8", errors="replace") if stdout 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.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Unhandled subprocess timeouts across the update phases. Three asyncio.wait_for(proc.communicate(), ...) calls have no except asyncio.TimeoutError. On timeout, wait_for cancels the wait but does not terminate the child, so git, uv, or pip keeps running while the exception escapes to run_full_update. run_full_update guards none of these calls, so the rollback and notify_drain_complete() steps are skipped and the worker stays unroutable. _run_helper already handles this case correctly; apply the same pattern, plus proc.kill().

  • tinyagentos/worker/self_update.py#L116-L133: wrap the wait_for in _run_git, kill the process, and return a non-zero exit code with a timeout message.
  • tinyagentos/worker/self_update.py#L291-L322: wrap both wait_for calls in update_dependencies, kill the process, and return {"ok": False, ...}.
📍 Affects 1 file
  • tinyagentos/worker/self_update.py#L116-L133 (this comment)
  • tinyagentos/worker/self_update.py#L291-L322
🤖 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/self_update.py` around lines 116 - 133, Handle subprocess
timeouts consistently across both update sites: in
tinyagentos/worker/self_update.py lines 116-133, update _run_git to catch
asyncio.TimeoutError around proc.communicate(), kill the process, and return a
non-zero status with a timeout message; in tinyagentos/worker/self_update.py
lines 291-322, apply the same timeout handling to both wait_for calls in
update_dependencies, killing each process and returning {"ok": False, ...} so
callers can continue rollback and completion handling.

Comment on lines +517 to +531
if not pull["ok"]:
logger.error("self-update: pull failed — aborting update")
# Don't rollback yet — we haven't installed anything.
await agent.notify_drain_complete()
return {"ok": False, "error": "pull failed", "phases": results}

# ── Phase 6: Update dependencies ──────────────────────────────
logger.info("self-update: phase 6 — updating dependencies")
deps = await update_dependencies()
results["dependencies"] = deps
if not deps["ok"]:
logger.error("self-update: dependency update failed — rolling back")
await rollback_to_checkpoint(checkpoint_tag)
await agent.notify_drain_complete()
return {"ok": False, "error": "dependency update failed", "phases": results}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) Does notify_drain_complete reset the worker lifecycle status?
fd -t f 'agent.py' -p tinyagentos/worker --exec ast-grep outline {} --items all
rg -nP -C 12 'def notify_drain_complete' tinyagentos/worker/agent.py
rg -nP -C 3 '_lifecycle_status\s*=' tinyagentos/worker/agent.py

# 2) What does the helper's rollback command restore?
fd -t f 'taos-deploy-helper.sh' --exec rg -nP -C 25 'rollback\)|^cmd_rollback|checkpoint\)'

Repository: jaylfc/taOS

Length of output: 7001


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== agent lifecycle and heartbeat sections ==="
sed -n '280,355p;700,746p' tinyagentos/worker/agent.py | cat -n

echo "=== update_dependencies and rollback call sites ==="
sed -n '300,355p;370,410p;410,440p;517,531p' tinyagentos/worker/self_update.py | cat -n

echo "=== helper rollback complete section ==="
sed -n '347,410p' tinyagentos/worker/taos-deploy-helper.sh | cat -n

Repository: jaylfc/taOS

Length of output: 12763


Reset the worker lifecycle status on abort, and restore the virtualenv before rollback.

notify_drain_complete() only sends a normal heartbeat; it does not reset the worker lifecycle status. Clear self_update_status or pass a status clear after the heartbeat in both abort paths so the controller can re-route traffic.

The Phase 6 rollback path runs update_dependencies() before rollback_to_checkpoint(checkpoint_tag). Restore the dependency environment as part of the rollback; otherwise the worker can run old application code with the partially updated dependencies.

🤖 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/self_update.py` around lines 517 - 531, Update both abort
paths in the self-update flow around pull failure and dependency failure to
clear self_update_status after notify_drain_complete(), allowing traffic to be
re-routed. In the Phase 6 dependency failure path, restore the
virtualenv/dependency environment before calling
rollback_to_checkpoint(checkpoint_tag), while preserving the existing rollback
and return behavior.

Comment on lines +682 to +691
# Wait for grace period to let backends stabilise.
logger.info(
"self-update: waiting %ds grace period for backends",
POST_RESTART_GRACE_PERIOD,
)
await asyncio.sleep(POST_RESTART_GRACE_PERIOD)

# ── Health check ──────────────────────────────────────────────
logger.info("self-update: running post-restart health check")
health = await run_health_check()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail
rg -nP -C 8 'post_update_startup' tinyagentos/worker/agent.py

Repository: jaylfc/taOS

Length of output: 1924


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "## self_update.py sections"
sed -n '630,750p' tinyagentos/worker/self_update.py
echo
echo "## agent.py around WorkerAgent.run"
sed -n '780,885p' tinyagentos/worker/agent.py
echo
echo "## search heartbeat/register refresh tasks"
rg -n -C 4 'heartbeat|register|_refresh|_heartbeat|WorkerAgent\.run|run\(' tinyagentos/worker/agent.py tinyagentos/worker | head -n 220

Repository: jaylfc/taOS

Length of output: 25222


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "## self_update health-check helpers"
rg -n -C 6 'def run_health_check|_run_helper|POST_RESTART_GRACE_PERIOD|DEFAULT_.*TIMEOUT|timeout' tinyagentos/worker/self_update.py tinyagentos/worker | head -n 240

echo
echo "## exact self_update imports/constants"
sed -n '1,80p' tinyagentos/worker/self_update.py

Repository: jaylfc/taOS

Length of output: 18753


Schedule post_update_startup() outside the WorkerAgent.run() await path.

WorkerAgent.run() awaits post_update_startup() directly, and that coroutine sleeps for 15s before run_health_check() can add another default 600s wait. With an update-in-progress marker present, send the first post-restart heartbeat on a background task or use a short explicit timeout so the loop cannot miss controller registration/heartbeat deadlines.

🤖 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/self_update.py` around lines 682 - 691, Update the
WorkerAgent.run flow so post_update_startup() is scheduled as a background task
rather than awaited inline, allowing the worker loop to register and send
heartbeats immediately. Preserve the existing startup health-check behavior
while ensuring the task is tracked or safely handled during shutdown.

Comment on lines +693 to +720
if not health["ok"]:
logger.error(
"self-update: health check FAILED — rolling back to %s",
checkpoint_tag,
)
# Attempt rollback
rollback_result = await rollback_to_checkpoint(checkpoint_tag)

# Signal rollback outcome (best-effort — we may not reach the
# controller if networking is the problem).
await signal_update_outcome(
controller_url=controller_url,
worker_name=agent.name,
outcome="rollback",
from_version=from_sha,
to_version=to_sha,
failure_reason=f"health-check: {health.get('output', 'unknown')}",
rollback_to=from_sha,
signing_key=agent._signing_key,
)

clear_update_marker(state_dir)
return {
"ok": False,
"outcome": "rollback",
"rollback": rollback_result,
"health": health,
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail
fd -t f 'taos-deploy-helper.sh' --exec rg -nP -C 30 'rollback\)|cmd_rollback|systemctl (restart|start)|launchctl'

Repository: jaylfc/taOS

Length of output: 11447


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'Locate relevant helpers:\n'
fd -t f 'self_update.py|*deploy-helper*.sh|*.sh' | sed -n '1,120p'

printf '\nOutline self_update.py around rollback/signaling helper definitions:\n'
wc -l tinyagentos/worker/self_update.py
ast-grep outline tinyagentos/worker/self_update.py --match rollback_to_checkpoint --view expanded || true
ast-grep outline tinyagentos/worker/self_update.py --match signal_update_outcome --view expanded || true

printf '\nRelevant sections in tinyagentos/worker/self_update.py:\n'
sed -n '600,750p' tinyagentos/worker/self_update.py

printf '\nSearch rollback helper invocation in self_update.py:\n'
rg -n -C 6 'rollback_to_checkpoint|signal_update_outcome|clear_update_marker|POST_RESTART_GRACE_PERIOD' tinyagentos/worker/self_update.py

printf '\nInspect marker state and rollback helper invocation in git:\n'
rg -n -C 4 'class.*Update|marker|rollback|POST_RESTART|POST_RESTART_GRACE_PERIOD' tinyagentos || true

Repository: jaylfc/taOS

Length of output: 507


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'Locate relevant files:\n'
fd -a 'self_update.py|taos-deploy-helper.sh|deploy.*(helper|-helper).*|install-worker.sh|worker.*\.sh' . 2>/dev/null | sed 's#^\./##' | sed -n '1,160p'

printf '\nGit-tracked candidates:\n'
git ls-files | rg '(^|/)self_update\.py$|(^|/)taos-deploy-helper\.sh$|(^|/)install-worker\.sh$|(^|/)worker.*\.sh$' || true

printf '\nRelevant sections in tinyagentos/worker/self_update.py:\n'
wc -l tinyagentos/worker/self_update.py
sed -n '1,120p' tinyagentos/worker/self_update.py
sed -n '600,750p' tinyagentos/worker/self_update.py

printf '\nSearch rollback helper invocation in self_update.py:\n'
rg -n -C 6 'rollback_to_checkpoint|signal_update_outcome|clear_update_marker|POST_RESTART_GRACE_PERIOD|update_marker' tinyagentos/worker/self_update.py || true

printf '\nMarker/rollback state search in tinyagentos:\n'
rg -n -C 4 'update[_A-Z0-9]*marker|marker|rollback|POST_RESTART|POST_RESTART_GRACE_PERIOD|attempts|attempt' tinyagentos || true

printf '\nRollback helper relevant section:\n'
sed -n '340,405p' scripts/taos-deploy-helper.sh 2>/dev/null || true
rg -n -C 8 'cmd_rollback|_detached_restart_worker|clear.*marker|marker|TAOS_CHECKPOINT|rollback-manifest|restore|checkout|git' scripts/taos-deploy-helper.sh 2>/dev/null || true

Repository: jaylfc/taOS

Length of output: 50368


Exit before rollback_to_checkpoint() and add a retry cap.

cmd_rollback() clears the worker after restoring the checkpoint, and rollback_to_checkpoint() is awaited before signal_update_outcome() / clear_update_marker(). This means the controller may not receive rollback and the marker may survive. Do the outcome signal and marker clear before the rollback; use an exit or helper that keeps the current post-rollback process alive. Store an attempt count in update-in-progress.json and stop after a fixed number of failed post-restart health checks.

🤖 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/self_update.py` around lines 693 - 720, The failed
health-check path should signal the rollback outcome and clear the update marker
before invoking rollback_to_checkpoint(), then terminate or delegate in a way
that preserves the current post-rollback process behavior. Add an attempt
counter to update-in-progress.json, increment it across restarts, and stop
retrying after a fixed cap of failed post-restart health checks.

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