Skip to content

feat(server): add DELETE /v1/hosts/{host_id} to deregister a retired host (#2038) - #3238

Closed
abhay-codes07 wants to merge 4 commits into
omnigent-ai:mainfrom
abhay-codes07:feat/deregister-host-route
Closed

feat(server): add DELETE /v1/hosts/{host_id} to deregister a retired host (#2038)#3238
abhay-codes07 wants to merge 4 commits into
omnigent-ai:mainfrom
abhay-codes07:feat/deregister-host-route

Conversation

@abhay-codes07

Copy link
Copy Markdown
Contributor

Related issue

Closes #2038

Summary

External hosts self-register via the daemon (POST /hosts) and become rows in the host store, but server/routes/hosts.py exposed only GET/POST. There was no supported way to remove one, so a permanently decommissioned machine (a retired runner VM, a torn-down IaC host) lingered as an offline entry in every session-creation host picker forever. The only workaround was hand-deleting the postgres row.

The persistent teardown already exists: HostStore.delete_host unbinds any sessions still pointing at the host (nulls their host_id) and deletes the row, which also revokes its launch token. This PR wires it to a route so retiring a host has a first-class API and a clean IaC lifecycle story (register is automatic, now deregister is too).

DELETE /v1/hosts/{host_id}:

  • Mirrors get_host's auth: require_user, 404 for an unknown host, 403 when the caller is not the owner.
  • Refuses an online host with 409 (retire it after it goes offline), so an active machine is not pulled out from under running work.
  • Refuses a server-managed sandbox host with 409: its lifecycle belongs to the server that created it, and deleting only the row would orphan the live sandbox.
  • Returns 204 on success; the host is gone from the store and the pickers, and any bound session is unbound.

Scope: this adds the missing server primitive the issue asks for. SDK-client / CLI / web-picker affordances can build on it as follow-ups.

Test Plan

Added to tests/server/integration/test_hosts_api.py:

  • test_delete_host_removes_offline_host — 204, row gone from store and picker
  • test_delete_host_404_unknown
  • test_delete_host_409_when_online — refuses a live host, leaves it in place
  • test_delete_host_409_managed_sandbox — refuses a server-managed host
  • test_delete_host_unbinds_bound_sessions — a bound session's host_id is nulled, not left dangling
  • test_delete_host_403_wrong_owner — one user cannot deregister another's host
OMNIGENT_SKIP_WEB_UI=true uv run pytest tests/server/integration/test_hosts_api.py -q
# 31 passed

Each DELETE-path test fails against main with 405 (no route) and passes with the change (verified by stashing the route). ruff check + ruff format --check clean.

Demo

N/A. Backend REST route with no visual surface of its own. Before: no way to remove a retired host; DELETE /v1/hosts/{id} returns 405 and the host lingers in every picker. After: DELETE /v1/hosts/{id} returns 204 and the host is gone (409 if it is still online or server-managed, 403/404 for the auth cases).

Type of change

  • Bug fix
  • Feature
  • UI / frontend change
  • Refactor / chore
  • Docs
  • Test / CI
  • Breaking change

Test coverage

  • Unit tests added / updated
  • Integration tests added / updated
  • E2E tests added / updated
  • Manual verification completed
  • Existing tests cover this change
  • Not applicable

Coverage notes

The route's own logic (auth, the online / managed / owner guards, the 204 path) is covered directly; the unbind-on-delete behavior is asserted end to end through the route. The underlying HostStore.delete_host was already exercised by its managed-host teardown callers.

Changelog

Added DELETE /v1/hosts/{host_id} so a retired self-registered host can be deregistered instead of lingering in the session-creation host picker forever.

…host (omnigent-ai#2038)

External hosts self-register via the daemon and become rows in the host
store, but `server/routes/hosts.py` exposed only GET/POST, so there was
no supported way to remove one. A permanently decommissioned machine
lingered as an offline entry in every session-creation host picker, and
the only workaround was hand-deleting the postgres row.

The persistent teardown already exists: `HostStore.delete_host` unbinds
any sessions still pointing at the host (nulls their `host_id`) and
deletes the row (which also revokes its launch token). This wires it to
a route so retiring a host has a first-class API and a clean IaC story.

`DELETE /v1/hosts/{host_id}` mirrors `get_host`'s auth: 404 for an
unknown host, 403 when the caller is not the owner. It refuses an online
host (409) so an active machine is not pulled out from under running
work, and refuses a server-managed sandbox host (409) whose lifecycle
belongs to the server that created it. On success it returns 204 and the
host is gone from the store and the pickers.

Tests: removes an offline host (row and picker), 404 unknown, 409 online,
409 managed sandbox, 403 wrong owner, and that a bound session is unbound
rather than left dangling at a deleted row. Each DELETE-path test fails
against `main` with 405 (no route) and passes with it.

Signed-off-by: abhay-codes07 <abhaysingh0293@gmail.com>
Copilot AI review requested due to automatic review settings July 24, 2026 14:29

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@github-actions github-actions Bot added the size/M Pull request size: M label Jul 24, 2026
@github-actions
github-actions Bot requested a review from SabhyaC26 July 24, 2026 14:29
Signed-off-by: abhay-codes07 <abhaysingh0293@gmail.com>
Copilot AI review requested due to automatic review settings July 24, 2026 15:21

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@github-actions github-actions Bot added size/L Pull request size: L and removed size/M Pull request size: M labels Jul 24, 2026
@github-actions

Copy link
Copy Markdown
Contributor

@abhay-codes07 This PR is a Bug fix, Feature, or UI / frontend change but the Demo section is missing or only contains a placeholder.

These change types require a screenshot or screen recording so reviewers can see the new behaviour without checking out the branch. Please update the Demo section with:

  • A screenshot or screen recording of the change, or
  • A link to a hosted video or GIF showing the new behaviour.

Use N/A only when the change has no user-visible effect whatsoever (e.g. a pure refactor or test-only change). If that's the case, uncheck the relevant type box and check Refactor / chore or Test / CI instead.

@github-actions github-actions Bot added the needs-demo PR needs a demo screenshot or recording label Jul 24, 2026
Deleting a host nulled only omnigent_conversation_metadata.host_id and
left runner_id, workspace, and git_branch pointing at a machine that no
longer exists. A session in that state is wedged in every direction: it
cannot auto-relaunch (that path is gated on host_id), cannot rebind to a
new host (the atomic bind matches only runner_id IS NULL, so it returns
"session already has a runner bound" forever), and cannot be stopped
(Stop requires both host_id and runner_id). There is no API that repairs
it, and no foreign key fails loudly to signal the inconsistency.

Null all four columns in the same statement instead, matching the full
unbind ConversationStore.clear_host_binding already performs for a
failed per-session bind.

The change lands in the shared primitive rather than behind a flag for
the new deregistration route, because it is correct for the only other
caller too: managed-sandbox teardown runs after the sandbox has been
terminated, so a surviving runner_id there is just as stale.

Signed-off-by: SabhyaC26 <sabhyachhabria@gmail.com>
A scheduled task pinned to a connected host stores that host_id as a
soft reference with no foreign key. Once the host row is gone the task
fails on every single fire with host_not_found, and the user cannot
repair it in place: the PATCH validator rejects a null host_id, so the
only way out is to delete the task and build it again from scratch.
Managed hosts were never pinnable, so deregistering a connected host is
the first path that can create this orphan.

Refuse the delete with 409 while any task still pins the host, naming
the tasks so the caller knows what to fix. Silently unpinning would be
worse than the 409: an unpinned task resolves the owner's first online
host at fire time, so clearing the pin would quietly relocate the work
to a different machine. Re-pointing a task at another host is already a
single PATCH, which makes the refusal recoverable with the surface that
exists. Paused tasks count too — they break identically once resumed.

Reading the tasks needs a host-scoped query rather than a filter over
the owner's tasks, because single-user deployments store tasks with a
null user_id while their hosts are owned by the reserved "local" user,
so an owner filter would miss every pinned task there.

Also corrects the route docstring, which is published to the public API
reference through openapi.json: it promised the host would stay out of
the pickers "forever" and claimed the delete revokes a launch token.
Neither holds for the hosts this route can delete. Only managed hosts
ever carry a token and those are refused, and an external host keeps its
host_id locally, so its daemon recreates the row on the next reconnect.

Signed-off-by: SabhyaC26 <sabhyachhabria@gmail.com>
Copilot AI review requested due to automatic review settings July 27, 2026 23:29

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review is ineligible. To be eligible to request a review, you need a paid Copilot license, or your organization must enable Copilot code review.

@github-actions github-actions Bot added size/XL Pull request size: XL and removed size/L Pull request size: L labels Jul 27, 2026
@SabhyaC26

Copy link
Copy Markdown
Contributor

/review

@omnigent-ci

omnigent-ci Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Polly AI Review

Blocking issues

None. The route is correct and well-scoped:

  • Auth mirrors get_host exactly (require_user → 404 unknown → 403 non-owner), and the ownership check precedes the sandbox/online/pinned guards, so an attacker cannot probe another user's host state via the 409 branches.
  • The guard ordering (owner → server-managed → online → pinned) is sound, and each returns 409 without mutating state; only the final path calls delete_host.
  • create_hosts_router's new scheduled_task_store parameter is keyword-only with a None default, so existing callers and the minimal test wirings stay valid; app.py now threads the real store through.
  • The delete_host store change correctly widens the unbind to null runner_id/workspace/git_branch alongside host_id. This is the right fix: a surviving runner_id would wedge the session forever (rebind matches only runner_id IS NULL), and clearing host_id+workspace together respects ck_conversation_metadata_workspace_required_for_host. Tests assert the row is genuinely reusable afterward.
  • list_by_host_id is scoped to current_workspace_id() and ordered consistently with list, and the 409 detail only names tasks in the caller's own workspace.

Security vulnerabilities

None introduced. The endpoint destroys no credential on this path (only server-managed hosts hold launch tokens and those are refused with 409), and it does not weaken the existing auth boundary — it reuses the same require_user + owner-equality contract as the read route. When auth is disabled (user_id is None) any caller can delete, but that is identical to the existing single-user posture of get_host/list_hosts, not a regression.

Non-blocking notes

  • TOCTOU on the liveness/pin checks. A host can transition online (or a task can be pinned) between the guard reads and delete_host. The window is small and the store's delete is idempotent, so the worst case is a host that briefly went online being deregistered while it had no bound work — acceptable, but worth an inline note that the guard is best-effort rather than transactional.
  • sandbox_provider check precedes the online check, so an online server-managed host reports "server-managed" rather than "online". Both are 409 and both refuse, so this is harmless; just calling out the deliberate precedence.
  • The test_delete_host_* and list_by_host_id tests are thorough (offline delete, 404, online 409, managed 409, full unbind, wrong-owner 403, paused-task counting, repoint-then-succeed). Coverage matches the stated contract well.

Summary

A clean, well-guarded backend addition that fills a real lifecycle gap (self-registered hosts could never be deregistered). Auth reuses the proven get_host pattern, the guards are ordered safely, the store's fuller unbind fixes a genuine session-wedging hazard, and the test suite exercises every branch including authz and the scheduled-task pin edge cases. No blocking or security concerns. As a pure backend REST route with no user-visible surface, no visual demonstration is warranted. Ready to merge pending the usual gates.


Automated review by Polly · workflow run

@SabhyaC26

Copy link
Copy Markdown
Contributor

@abhay-codes07 who is the call of this API? not sure if we should add it for the sake of adidng it

@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Closing this PR because it has been labeled waiting-on-author for 7 days without an author reply or new commit.

The label was last applied on 2026-07-27T23:39:43Z. If you are ready to continue, please reopen this PR or open a new one.

@abhay-codes07

Copy link
Copy Markdown
Contributor Author

This auto-closed on my inactivity and GitHub would not let me reopen it (422 after the branch was rebased), so I continued it in #4360: same branch rebased onto current main, with your two follow-up commits preserved (@SabhyaC26). It also answers your earlier "who calls this?" question in the description. Thanks for the fully-unbind fix and the scheduled-task-pin guard.

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

Labels

needs-demo PR needs a demo screenshot or recording size/XL Pull request size: XL waiting-on-author

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature] No way to deregister/delete an external self-registered host — retired hosts linger in the host picker forever

3 participants