Skip to content

feat: agent self-update across all pull modes — K8s (Helm) + OS-service launcher (Linux/macOS/Windows)#69

Open
yonatan-alien wants to merge 52 commits into
mainfrom
yonatan/alien-59-self-update-for-the-alien-agent-across-all-pull-mode-2
Open

feat: agent self-update across all pull modes — K8s (Helm) + OS-service launcher (Linux/macOS/Windows)#69
yonatan-alien wants to merge 52 commits into
mainfrom
yonatan/alien-59-self-update-for-the-alien-agent-across-all-pull-mode-2

Conversation

@yonatan-alien

@yonatan-alien yonatan-alien commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes ALIEN-59. Manager-driven agent self-update on Kubernetes, plus the two production-hardening fixes that came out of live e2e.

Feature

  • New agent_upgrade loop in alien-agent consumes agent_target.helm on /v1/sync and spawns a Helm-runner Job that runs helm upgrade --reuse-values --set runtime.image.tag=<target> under an alien-agent-upgrader ServiceAccount.
  • alien-helm chart guardrails — required management.{url,name,token,deploymentId}, /livez+/readyz probes, Recreate strategy, namespace-scoped upgrader Role/Binding, upgrader.enabled opt-out.
  • Sync schema gains agent_version / agent_os / agent_arch / regime / agent_image_repository; K8s worker transport set to local; K8s ServiceAccount controller is skipped (chart owns it).
  • alien-manager exposes PUT /v1/deployments/:id/target-agent-version; build_agent_target emits agent_target.helm when target ≠ reported and regime = kubernetes.

Production hardening (would silently break otherwise; both found via live e2e)

  • fix(helm): enable agent runtime-data persistence by default — without a PVC, the self-update's pod roll wipes the agent's deployment_id/sync token, the new pod hits 409 from initialize, CrashLoopBackOffs, and helm --atomic rolls back. Documented opt-out.
  • feat(agent,manager): /v1/rejoin endpoint — for the case where persistence is genuinely off (or the state directory is wiped for any other reason), the agent's initialize-409 falls through to a new POST /v1/rejoin that re-acquires a deployment-scoped sync token over the existing row. New RouterOptions::include_rejoin + skip_rejoin() builder lets multi-tenant embedders override, same pattern as /v1/initialize.
  • fix(agent): preserve 409 from initialize so rejoin fall-through fires — the agent's .context(ConfigurationError) was clobbering the SDK's real 409 with a hardcoded 500. New DeploymentNameAlreadyExists variant (409) lets the conflict surface so the rejoin branch actually triggers.

Paired changes are tracked in a separate PR.

Windows OS-service self-update (launcher/operator path)

This branch also lands Phase 3 (Windows) of the OS-service self-update path —
the frozen alien-launcher supervising alien-operator with health-gated binary
swaps + last-stable rollback, now on Windows via the Service Control Manager
(Linux/systemd and macOS/launchd already on the branch). All cross-platform
launcher core/ logic is shared; only the per-OS shims are new.

  • SCM ServiceHost + service entry (T3.1): platform/windows.rs maps SCM
    Stop/Shutdown/Interrogate ↔ the launcher's control channel; main.rs's Windows
    run_supervisor hands control to service_dispatcher (service mode) or a
    Ctrl-C handler (--console, for tests/E2E), then drives the shared core::run.
  • Job-Object ChildSupervisor (T3.2): spawns the operator in a kill-on-close
    Job (command-group) so it dies with the launcher; graceful stop via
    CTRL_BREAK, escalating to Job termination after a grace window.
  • Die-with-parent for the workload + graceful stop (T3.3): the operator now
    places the user application in its own kill-on-close Job (a new AppChild
    wrapper — the Windows counterpart to Linux PR_SET_PDEATHSIG) so a self-update
    handoff can't orphan it, and honors CTRL_BREAK for a clean shutdown.
  • Junction VersionStore (T3.4): current/last-stable are directory
    junctions; the non-atomic flip reconciles crash residue on the next start
    (finish-flip / reconstruct-from-probation); gc tolerates a locked
    (still-mapped) operator .exe by scheduling reboot-deletion instead of failing.
    The full Phase-0 state-machine suite runs unchanged over this store.
  • Installer (T3.5): alien-deploy operator install registers
    alien-launcher.exe as the SCM service under %ProgramData%\alien-operator
    with a service recovery config (sc failure: reset 24h, three 5s restarts);
    junction-seeded layout with exe-suffixed binaries; --no-launcher still keeps
    the legacy direct-operator service.
  • Windows CI (T3.7): a windows-os-service job runs the platform unit +
    state-machine tests and a live SCM smoke (sc create → RUNNING → qfailure → stop → delete, with a stub operator answering /livez) on native Windows.

Deferred (tracked in the internal plan): Authenticode code-signing — a
cross-platform signing workstream, since nothing is signed on any platform today
— and porting the seven-scenario E2E rig to Windows (it's Unix-only: symlinks,
/bin/sh version wrappers, pgrep/ps, a musl workload OCI build). The Windows
platform tests + SCM smoke exercise the same store/child/SCM logic in the meantime.

Test plan

  • cargo test -p alien-agent -p alien-core -p alien-deployment -p alien-helm -p alien-manager --lib
  • Live e2e against local managerx + orbstack K8s: pin a target version in the dashboard → manager emits agent_target → helm-upgrade Job rolls the agent pod → PVC keeps deployment_id → new agent reports the new version
  • Live e2e for rejoin: kubectl exec rm -rf /var/lib/alien-agent/* + bounce pod → agent logs Name already exists — assuming local state was wiped, rejoining… → recovers the original deployment_id and stays running
  • Reviewer to confirm: the vendored client-sdks/platform/* openapi spec + generated SDK — the v1.13.2 main merge took main's version, because main's feat: machines setup handoff and endpoint status updates #120/fix: update cli platform request fields #121 added types (NewDeploymentRequestInitialDesiredRelease, KubernetesClusterSource, DeploymentSetupConfigPublicSubdomain, …) that the auto-merged alien-cli source depends on (keeping ours no longer compiled). Consequence: this branch's rejoin / target-agent-version schema additions are currently dropped from the vendored spec — the reverse of the earlier merge. The /v1/rejoin + target-operator-version manager-side code and the manager SDK are intact; only the vendored SDK mirror is affected. A subsequent openapi regen must reconcile so both this branch's additions and main's land in the vendored SDK.
  • Windows CI (windows-os-service job) green on native Windows: cargo test -p alien-launcher -p alien-operator (junction store + Phase-0 state-machine suite + Job-Object kill-on-close + SCM control mapping) and a live SCM smoke (sc create → RUNNING → sc qfailure → sc stop → sc delete) — run 29160469336

Yonatan Schkolnik added 7 commits June 2, 2026 15:43
…IEN-59)

Squashed replay of the K8s self-update feature onto current main:

- `alien-agent`: new `agent_upgrade` loop that consumes `agent_target.helm`
  from /v1/sync and spawns a Helm-upgrade Job using the upgrader SA; sync
  loop persists deployment-scoped token across restarts.
- `alien-helm`: chart guardrails — `required management.{url,name,token,
  deploymentId}`, /livez + /readyz probes, Recreate strategy, upgrader SA
  + namespace-scoped Role, `upgrader.enabled` values block.
- `alien-core`: sync schema gains `agent_version`, `agent_os`, `agent_arch`,
  `regime`, `agent_image_repository`; K8s worker transport set to `local`.
- `alien-manager`: PUT /v1/deployments/:id/target-agent-version; sync emits
  `agent_target` when `target_agent_version` differs from the agent's
  reported version; sqlite store + migrations + ReconcileData carry the
  new inventory; sync-token persistence reuses across restarts.
- `alien-infra`: K8s ServiceAccount controller is skipped — the chart owns
  it, controllers reference it by name.
- `alien-deployment`: Kubernetes platform routes through local env-info
  collection (k8s-on-cloud picks the base cloud via existing context).
Copy the regenerated `apps/api/openapi.json` from the platform repo so
the Rust SDK (`alien-platform-api`, generated via `progenitor` at build
time) picks up the new `PUT /v1/deployments/:id/target-agent-version`
endpoint, the extended SyncReconcileRequest fields, and the
`basePlatform` property on `PersistImportedDeploymentRequest`.
Without a PVC backing `/var/lib/alien-agent`, the agent's `deployment_id`
+ deployment-scoped sync token live in an `emptyDir` and are wiped on any
pod restart. The self-update flow (manager emits `agent_target.helm` →
agent spawns helm-upgrade Job → Job rolls the agent Deployment) triggers
exactly such a restart, and the new pod re-runs `/v1/initialize`, hits a
DEPLOYMENT_NAME_ALREADY_EXISTS 409, CrashLoopBackOffs, helm `--atomic`
times out, and rolls back. The feature is silently unusable.

Default `runtime.data.persistence.enabled` to `true` so self-update
survives the pod roll on any cluster with a default StorageClass.
Operators on clusters without one can still set `storageClassName`,
point at an `existingClaim`, or explicitly opt out and accept that
self-update won't survive a pod roll.

Helm snapshot tests rebaselined.
The agent's `data_dir` (`/var/lib/alien-agent`) holds its persistent
`deployment_id` + deployment-scoped sync token. When the chart leaves
`runtime.data.persistence` off (or anything else wipes that directory —
pod eviction, manual reset, etc.) the agent restarts with no stored
state and falls into the "first startup" branch, hits `/v1/initialize`,
and the platform rejects with `DEPLOYMENT_NAME_ALREADY_EXISTS 409`
because the row is still there. Result: CrashLoopBackOff, and if the
restart was driven by self-update's helm-upgrade Job, helm `--atomic`
times out and rolls back.

Add `POST /v1/rejoin` so the agent can explicitly re-attach to an
existing deployment by name. Same dg bearer the chart mounts, no body
beyond the name; response is `{ deploymentId, token }` with a freshly
minted deployment-scoped token. The agent code-path catches the 409
from initialize and falls through to rejoin instead of erroring.

- `alien-manager`: new `RejoinRequest`/`RejoinResponse`, `rejoin`
  handler that mirrors the existing initialize idempotency branch
  (look up by `(dg, name)`, mint a fresh token), wired through a new
  `rejoin_router()` so multi-tenant embedders can override it the same
  way they override initialize. `RouterOptions::include_rejoin` + a
  `skip_rejoin()` builder method follow the existing pattern.
- `alien-agent`: on 409 from initialize, resolve the deployment name
  the same way initialize did (CLI arg / env / hostname) and call the
  new `rejoin_with_manager` helper. The recovered `(deployment_id,
  token)` are written back to `data_dir` like a normal init, so
  subsequent restarts go through the stored-state branch.
- `alien-manager-api` SDK regenerated; OSS `/v1/rejoin` is exposed in
  the openapi schema for downstream tooling.
The agent's initialize → rejoin fall-through keyed off
`http_status_code == Some(409)`, but `initialize_with_manager` wrapped
every SDK error in `ConfigurationError` whose hardcoded
`http_status_code = 500` clobbered the real status. Result: agent
received "Unexpected response: 409 Conflict" from the manager, hit the
`.context(ConfigurationError)` wrap, the outer status became 500, and
the agent crashed instead of falling through to `/v1/rejoin`.

- New `DeploymentNameAlreadyExists` variant on `ErrorData` (409,
  non-retryable, non-internal) so the 409 path has a distinct outer
  error.
- `initialize_with_manager` rebuilt: 409 from the SDK is re-emitted as
  `DeploymentNameAlreadyExists`; everything else still goes through
  `ConfigurationError` so existing error paths are unchanged.

Confirmed live: after wiping `/var/lib/alien-agent/*` and bouncing the
pod, the agent now logs `Name already exists — assuming local state was
wiped, rejoining…`, calls `/v1/rejoin`, recovers the same deployment_id,
and stays running.
…-update-for-the-alien-agent-across-all-pull-mode-2

# Conflicts:
#	client-sdks/platform/openapi.json
#	client-sdks/platform/rust/openapi-3.0.json
#	client-sdks/platform/rust/openapi.json
#	crates/alien-helm/src/generator.rs
#	crates/alien-helm/tests/generator/boot_paths_tests.rs
#	crates/alien-helm/tests/generator/snapshots/generator__generator__helpers__data_layer.snap
#	crates/alien-helm/tests/generator/snapshots/generator__generator__helpers__manager_only_pure_worker.snap
@greptile-apps

greptile-apps Bot commented Jun 3, 2026

Copy link
Copy Markdown

Greptile Summary

This PR lands manager-driven K8s agent self-update (Helm-runner Job spawned from the agent on /v1/sync), the /v1/rejoin recovery flow for wiped persistent state, and the chart guardrails (Recreate strategy, PVC-on-by-default, required value guards, /livez+/readyz probes) that make the upgrade cycle safe.

  • K8s self-update loop (agent_upgrade.rs): agent reads agent_target.helm from the sync response, resolves chart ref + runner image from env vars injected by the chart, and POSTs a one-shot Helm-runner Job to the in-cluster API using the alien-agent-upgrader SA.
  • /v1/rejoin endpoint (manager + agent cli.rs): catches the 409 from /v1/initialize (name already exists after a state wipe), re-acquires a fresh deployment-scoped sync token over the existing row, and persists it to the local SQLite state so pod restarts don't fall back to the chart-mounted DG token.
  • Chart hardening (generator.rs): PVC enabled by default, required template guards on management.*, Recreate rollout strategy, namespace-scoped upgrader Role/ClusterRoleBinding, and auto-generated encryption key via lookup.

Confidence Score: 3/5

The self-update happy path is solid, but a failed helm-runner Job silently blocks all retry attempts for up to 10 minutes with no operator warning.

The core upgrade loop, rejoin recovery, chart guardrails, and persistence defaults are well-reasoned and tested. The 409-on-failed-Job case in create_job quietly absorbs what should be a retryable error, and min_supported_version being pinned to the target version would block all upgrades once the agent enforces that field. The upgrade path is a new critical flow touching the agent self-replacement mechanism, so these gaps carry more weight than they would in a peripheral feature.

crates/alien-agent/src/loops/agent_upgrade.rs (failed-Job 409 handling) and crates/alien-manager/src/routes/sync.rs (build_agent_target min_supported_version) need the most attention before this ships to production.

Important Files Changed

Filename Overview
crates/alien-agent/src/loops/agent_upgrade.rs New Helm-runner Job spawner; 409 from a failed (but not yet TTL'd) Job is silently treated as idempotent success, stalling upgrades for up to 10 min with no operator warning.
crates/alien-manager/src/routes/sync.rs New agent_sync inventory persistence and build_agent_target; min_supported_version is set equal to the target version, which would block all agents from upgrading once that field is enforced.
crates/alien-manager/src/routes/deployments.rs New PUT /v1/deployments/:id/target-agent-version endpoint; auth, authz, and semver validation are present; is_plausible_semver rejects valid versions with both pre-release and build metadata.
crates/alien-helm/src/generator.rs Chart guardrails (required fields, Recreate strategy, persistence default, upgrader SA/Role/ClusterRole), encryption key auto-generation, and /livez+/readyz probe defaults all look correct.
crates/alien-agent/src/cli.rs Adds rejoin fall-through on 409 from initialize and sync-token persistence; logic and error routing look correct.
crates/alien-manager/src/stores/sqlite/deployment.rs update_agent_metadata and set_target_agent_version are correctly scoped; neither updates the updated_at column, leaving deployment timestamps stale after inventory syncs.
crates/alien-core/src/sync.rs SyncRequest/SyncResponse extended with new optional fields; AgentTarget, AgentHelmTarget, AgentBinaryTarget structs and back-compat tests look correct.
crates/alien-infra/src/core/executor.rs Kubernetes ServiceAccounts are correctly skipped at controller-lookup time and auto-marked Running as platform-provided resources; logic mirrors external-binding treatment.

Sequence Diagram

sequenceDiagram
    participant Agent
    participant Manager
    participant K8sAPI
    participant HelmRunnerJob

    Note over Agent,Manager: Normal sync loop
    Agent->>Manager: "POST /v1/sync (agent_version, regime=kubernetes)"
    Manager->>Manager: update_agent_metadata()
    Manager->>Manager: build_agent_target() if target differs from reported
    Manager-->>Agent: SyncResponse with agent_target helm payload

    Note over Agent,K8sAPI: Self-update actuator
    Agent->>K8sAPI: POST /apis/batch/v1/.../jobs (helm-runner Job)
    K8sAPI-->>Agent: 201 Created (or 409 if exists)
    K8sAPI->>HelmRunnerJob: schedule pod
    HelmRunnerJob->>HelmRunnerJob: helm upgrade --reuse-values --atomic --wait

    Note over Agent,Manager: Pod rolls (Recreate strategy)
    HelmRunnerJob->>K8sAPI: Deployment updated with new image tag
    Agent->>Agent: /readyz returns 503 until first sync
    Agent->>Manager: POST /v1/sync with new agent_version
    Agent->>Agent: "first_sync_completed=true, /readyz returns 200"

    Note over Agent,Manager: Rejoin after state wipe
    Agent->>Manager: POST /v1/initialize returns 409
    Agent->>Manager: POST /v1/rejoin with name
    Manager->>Manager: get_deployment_by_name() and mint new token
    Manager-->>Agent: deployment_id and fresh token
    Agent->>Agent: db.set_sync_token() persists for next restart
Loading

Comments Outside Diff (1)

  1. crates/alien-manager/src/stores/sqlite/deployment.rs, line 506-550 (link)

    P2 updated_at is not bumped during agent metadata writes

    update_agent_metadata (and similarly set_target_agent_version) never touches the updated_at column. Any API consumer that uses updated_at to detect "something changed on this deployment" will miss both inventory syncs and operator target-version pins. Consider setting updated_at = datetime('now') inside the same UPDATE, or at least doing so for set_target_agent_version which represents an explicit admin action that callers would expect to be reflected in the record's timestamp.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: crates/alien-manager/src/stores/sqlite/deployment.rs
    Line: 506-550
    
    Comment:
    **`updated_at` is not bumped during agent metadata writes**
    
    `update_agent_metadata` (and similarly `set_target_agent_version`) never touches the `updated_at` column. Any API consumer that uses `updated_at` to detect "something changed on this deployment" will miss both inventory syncs and operator target-version pins. Consider setting `updated_at = datetime('now')` inside the same UPDATE, or at least doing so for `set_target_agent_version` which represents an explicit admin action that callers would expect to be reflected in the record's timestamp.
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
Fix the following 4 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 4
crates/alien-agent/src/loops/agent_upgrade.rs:296-305
**Silently swallows a failed Job's 409 — upgrade stalls with no warning**

When the Helm-runner Job exists but is in a `Failed` state (exhausted its `backoffLimit`), the Kubernetes API still returns 409 on a new create. The code logs at `info` level and returns `Ok(())`, so `apply_agent_target` sees success. The manager keeps emitting `agent_target` on every sync tick, but the agent will keep hitting the same 409 and silently doing nothing for up to `ttlSecondsAfterFinished` seconds (10 minutes). During that window, there is no `warn` log and no error propagated — an operator watching logs will only see "Upgrader Job already exists (409); leaving in place" with no indication the actual upgrade never happened.

To distinguish "another sync already queued it" (desired idempotency) from "it exists but is already dead," the handler should query the Job status and emit at least a `warn` when the existing Job is in a `Failed` condition, so operators can investigate before the TTL window expires.

### Issue 2 of 4
crates/alien-manager/src/routes/sync.rs:1107-1112
`min_supported_version` is set equal to `version`, which will break upgrades once the field is enforced. The field is described as "the agent should refuse the upgrade if its own version is older than this." Setting it to the target version means only an agent already at version X would accept the upgrade to X — but those agents never receive the instruction (the `reported_version == Some(target_version)` guard returns `None` first). Every agent that actually needs upgrading would refuse. The MVP intent (allow all current agents to upgrade) is better expressed as an empty floor like `"1.0.0"`, or by explicitly recording the minimum agent version that is safe to self-upgrade.

```suggestion
    Some(alien_core::sync::AgentTarget {
        version: target_version.to_string(),
        // Allow all existing agents to receive the upgrade instruction.
        // Set to a meaningful floor (e.g. the first version that supports
        // self-update) once the agent enforces this field.
        min_supported_version: "1.0.0".to_string(),
        binary: None,
        helm,
    })
```

### Issue 3 of 4
crates/alien-manager/src/routes/deployments.rs:876-892
`splitn(2, |c| c == '-' || c == '+')` splits on the **first** occurrence of either separator, so versions with both a pre-release suffix and build metadata (e.g. `1.4.0-rc.1+build.123`) fail: the remainder becomes `"rc.1+build.123"` and the `+` isn't in the allowed character set `[alphanumeric, '.', '-']`. The function correctly rejects garbage input, but it silently blocks valid SemVer 2.0.0 strings that operators may reasonably try to pin.

```suggestion
fn is_plausible_semver(v: &str) -> bool {
    // Strip optional +build metadata first so the pre-release check can
    // use a simple split on '-'.
    let without_build = v.splitn(2, '+').next().unwrap_or(v);
    let mut parts = without_build.splitn(2, '-');
    let core = parts.next().unwrap_or("");
    let core_ok = {
        let segs: Vec<&str> = core.split('.').collect();
        segs.len() == 3
            && segs
                .iter()
                .all(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()))
    };
    let rest_ok = parts.next().map_or(true, |r| {
        !r.is_empty()
            && r.chars()
                .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-')
    });
    core_ok && rest_ok
}
```

### Issue 4 of 4
crates/alien-manager/src/stores/sqlite/deployment.rs:506-550
**`updated_at` is not bumped during agent metadata writes**

`update_agent_metadata` (and similarly `set_target_agent_version`) never touches the `updated_at` column. Any API consumer that uses `updated_at` to detect "something changed on this deployment" will miss both inventory syncs and operator target-version pins. Consider setting `updated_at = datetime('now')` inside the same UPDATE, or at least doing so for `set_target_agent_version` which represents an explicit admin action that callers would expect to be reflected in the record's timestamp.

Reviews (1): Last reviewed commit: "Merge remote-tracking branch 'origin/mai..." | Re-trigger Greptile

Comment on lines +296 to +305
}
out
}

#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;

fn inputs() -> JobInputs {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Silently swallows a failed Job's 409 — upgrade stalls with no warning

When the Helm-runner Job exists but is in a Failed state (exhausted its backoffLimit), the Kubernetes API still returns 409 on a new create. The code logs at info level and returns Ok(()), so apply_agent_target sees success. The manager keeps emitting agent_target on every sync tick, but the agent will keep hitting the same 409 and silently doing nothing for up to ttlSecondsAfterFinished seconds (10 minutes). During that window, there is no warn log and no error propagated — an operator watching logs will only see "Upgrader Job already exists (409); leaving in place" with no indication the actual upgrade never happened.

To distinguish "another sync already queued it" (desired idempotency) from "it exists but is already dead," the handler should query the Job status and emit at least a warn when the existing Job is in a Failed condition, so operators can investigate before the TTL window expires.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/alien-agent/src/loops/agent_upgrade.rs
Line: 296-305

Comment:
**Silently swallows a failed Job's 409 — upgrade stalls with no warning**

When the Helm-runner Job exists but is in a `Failed` state (exhausted its `backoffLimit`), the Kubernetes API still returns 409 on a new create. The code logs at `info` level and returns `Ok(())`, so `apply_agent_target` sees success. The manager keeps emitting `agent_target` on every sync tick, but the agent will keep hitting the same 409 and silently doing nothing for up to `ttlSecondsAfterFinished` seconds (10 minutes). During that window, there is no `warn` log and no error propagated — an operator watching logs will only see "Upgrader Job already exists (409); leaving in place" with no indication the actual upgrade never happened.

To distinguish "another sync already queued it" (desired idempotency) from "it exists but is already dead," the handler should query the Job status and emit at least a `warn` when the existing Job is in a `Failed` condition, so operators can investigate before the TTL window expires.

How can I resolve this? If you propose a fix, please make it concise.

Comment thread crates/alien-manager/src/routes/sync.rs Outdated
Comment on lines +1107 to +1112
Some(alien_core::sync::AgentTarget {
version: target_version.to_string(),
min_supported_version: target_version.to_string(),
binary: None,
helm,
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 min_supported_version is set equal to version, which will break upgrades once the field is enforced. The field is described as "the agent should refuse the upgrade if its own version is older than this." Setting it to the target version means only an agent already at version X would accept the upgrade to X — but those agents never receive the instruction (the reported_version == Some(target_version) guard returns None first). Every agent that actually needs upgrading would refuse. The MVP intent (allow all current agents to upgrade) is better expressed as an empty floor like "1.0.0", or by explicitly recording the minimum agent version that is safe to self-upgrade.

Suggested change
Some(alien_core::sync::AgentTarget {
version: target_version.to_string(),
min_supported_version: target_version.to_string(),
binary: None,
helm,
})
Some(alien_core::sync::AgentTarget {
version: target_version.to_string(),
// Allow all existing agents to receive the upgrade instruction.
// Set to a meaningful floor (e.g. the first version that supports
// self-update) once the agent enforces this field.
min_supported_version: "1.0.0".to_string(),
binary: None,
helm,
})
Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/alien-manager/src/routes/sync.rs
Line: 1107-1112

Comment:
`min_supported_version` is set equal to `version`, which will break upgrades once the field is enforced. The field is described as "the agent should refuse the upgrade if its own version is older than this." Setting it to the target version means only an agent already at version X would accept the upgrade to X — but those agents never receive the instruction (the `reported_version == Some(target_version)` guard returns `None` first). Every agent that actually needs upgrading would refuse. The MVP intent (allow all current agents to upgrade) is better expressed as an empty floor like `"1.0.0"`, or by explicitly recording the minimum agent version that is safe to self-upgrade.

```suggestion
    Some(alien_core::sync::AgentTarget {
        version: target_version.to_string(),
        // Allow all existing agents to receive the upgrade instruction.
        // Set to a meaningful floor (e.g. the first version that supports
        // self-update) once the agent enforces this field.
        min_supported_version: "1.0.0".to_string(),
        binary: None,
        helm,
    })
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +876 to +892
fn is_plausible_semver(v: &str) -> bool {
let mut parts = v.splitn(2, |c| c == '-' || c == '+');
let core = parts.next().unwrap_or("");
let core_ok = {
let segs: Vec<&str> = core.split('.').collect();
segs.len() == 3
&& segs
.iter()
.all(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()))
};
let rest_ok = parts.next().map_or(true, |r| {
!r.is_empty()
&& r.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-')
});
core_ok && rest_ok
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 splitn(2, |c| c == '-' || c == '+') splits on the first occurrence of either separator, so versions with both a pre-release suffix and build metadata (e.g. 1.4.0-rc.1+build.123) fail: the remainder becomes "rc.1+build.123" and the + isn't in the allowed character set [alphanumeric, '.', '-']. The function correctly rejects garbage input, but it silently blocks valid SemVer 2.0.0 strings that operators may reasonably try to pin.

Suggested change
fn is_plausible_semver(v: &str) -> bool {
let mut parts = v.splitn(2, |c| c == '-' || c == '+');
let core = parts.next().unwrap_or("");
let core_ok = {
let segs: Vec<&str> = core.split('.').collect();
segs.len() == 3
&& segs
.iter()
.all(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()))
};
let rest_ok = parts.next().map_or(true, |r| {
!r.is_empty()
&& r.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-')
});
core_ok && rest_ok
}
fn is_plausible_semver(v: &str) -> bool {
// Strip optional +build metadata first so the pre-release check can
// use a simple split on '-'.
let without_build = v.splitn(2, '+').next().unwrap_or(v);
let mut parts = without_build.splitn(2, '-');
let core = parts.next().unwrap_or("");
let core_ok = {
let segs: Vec<&str> = core.split('.').collect();
segs.len() == 3
&& segs
.iter()
.all(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()))
};
let rest_ok = parts.next().map_or(true, |r| {
!r.is_empty()
&& r.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-')
});
core_ok && rest_ok
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/alien-manager/src/routes/deployments.rs
Line: 876-892

Comment:
`splitn(2, |c| c == '-' || c == '+')` splits on the **first** occurrence of either separator, so versions with both a pre-release suffix and build metadata (e.g. `1.4.0-rc.1+build.123`) fail: the remainder becomes `"rc.1+build.123"` and the `+` isn't in the allowed character set `[alphanumeric, '.', '-']`. The function correctly rejects garbage input, but it silently blocks valid SemVer 2.0.0 strings that operators may reasonably try to pin.

```suggestion
fn is_plausible_semver(v: &str) -> bool {
    // Strip optional +build metadata first so the pre-release check can
    // use a simple split on '-'.
    let without_build = v.splitn(2, '+').next().unwrap_or(v);
    let mut parts = without_build.splitn(2, '-');
    let core = parts.next().unwrap_or("");
    let core_ok = {
        let segs: Vec<&str> = core.split('.').collect();
        segs.len() == 3
            && segs
                .iter()
                .all(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()))
    };
    let rest_ok = parts.next().map_or(true, |r| {
        !r.is_empty()
            && r.chars()
                .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-')
    });
    core_ok && rest_ok
}
```

How can I resolve this? If you propose a fix, please make it concise.

/// POST a Job to the in-pod Kubernetes API. Returns Ok on 2xx, AlienError
/// otherwise. 409 (already exists) is treated as success — another sync
/// already kicked the Job and idempotency handles the retry.
async fn create_job(namespace: &str, body: &serde_json::Value) -> Result<()> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

these APIs should be in alien-k8s-clients

Comment thread crates/alien-core/src/sync.rs Outdated
pub agent_version: Option<String>,
/// `linux` / `macos` / `windows`. From `std::env::consts::OS`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_os: Option<String>,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think we have an existing enum for this somewhere in alien-core

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

There is only Architecture for cloud instances (Arm64/X86_64). I'll make enums for the agent arch/os

Comment thread crates/alien-core/src/sync.rs Outdated
pub agent_os: Option<String>,
/// `x86_64` / `aarch64`. From `std::env::consts::ARCH`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_arch: Option<String>,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think we have an existing enum for this somewhere in alien-core

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

There is only Architecture for cloud instances (Arm64/X86_64)

Comment thread crates/alien-core/src/sync.rs Outdated
/// How the agent is supervised — `os-service` (launcher) or `kubernetes`
/// (Helm). Detected at runtime from `KUBERNETES_SERVICE_HOST`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub regime: Option<AgentRegime>,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

regime is a really weird name haha

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I'll change to packaging

exec helm upgrade \"$RELEASE\" \"$CHART_REF\"{version_flag}{extra_args_flag} \
--namespace \"$NAMESPACE\" \
--reuse-values \
--atomic --wait \

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can you document why did we choose --atomic?

--values /tmp/values.json"
);

let body = serde_json::json!({

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

What happens if creating this job fails? Or pulling the job's image fails? Or the command inside it fails? Do we report back those failures in any way?

How many retries do we support (I think the answer should be unlimited)? Is there exponential backoff or constant interval between retries?

"name": job_name,
"namespace": inputs.namespace,
"labels": {
"app.kubernetes.io/managed-by": "alien-agent",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We recently improved our white-labeling support, need to make sure it fits there. See other places in alien-helm

@@ -0,0 +1,453 @@
//! Agent self-update actuator. When `/v1/sync` returns

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Right now this will only work for Kubernetes deployments, right? No support for local service swap etc? What'll happen in local?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

As decided, we'll also include service implementation for Linux/Windows/Mac in this branch before merging.

Yonatan Schkolnik and others added 3 commits June 29, 2026 23:10
Conflict resolutions:
- cli.rs: keep main's select_startup_deployment_id refactor; re-apply our
  sync-token restore, stack-settings forwarding, and 409->rejoin fallthrough
  into its branches. Union imports (PublicEndpointUrls + ContextError).
- sync.rs / executor.rs: disjoint additions, kept both.
- ReconcileData / DeploymentRecord gained agent-inventory fields on our
  branch; set them to None at main's reconcile-loop site and two test
  fixtures (no agent sync there).
- manager openapi (crates + client-sdks/manager): regenerated from merged
  Rust via generate:manager-rust-sdk.
- client-sdks/platform/*: took main's (superset); pending re-sync from the
  platform-side openapi regen.

Validated: cargo test -p alien-agent -p alien-core -p alien-deployment
-p alien-helm -p alien-manager --lib -> 518 passed, 0 failed.

NOTE: manager TypeScript SDK not regenerated (speakeasy needs interactive
auth); rerun `pnpm run generate:manager-api` after `speakeasy auth login`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replaces the main-only platform spec taken during the origin/main merge
with the freshly regenerated apps/api/openapi.json (main + the ALIEN-59
rejoin / target-agent-version / agent-inventory additions). Closes the
"next platform regen will bring main's schemas back" note from the merge.
Validated: cargo check -p alien-platform-api -p alien-bindings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Resolve sync.rs (keep detect_agent_regime alongside apply_manager_control_state)
and regenerate the platform API specs and Cargo.lock from the merged code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@yonatan-alien yonatan-alien changed the title ALIEN-59: K8s agent self-update — chart guardrails, /v1/rejoin recovery, persistence default feat: K8s agent self-update — chart guardrails, /v1/rejoin recovery, persistence default Jun 30, 2026
Yonatan Schkolnik and others added 16 commits July 5, 2026 12:28
…-update-for-the-alien-agent-across-all-pull-mode-2

# Conflicts:
#	Cargo.lock
#	client-sdks/platform/openapi.json
#	client-sdks/platform/rust/openapi-3.0.json
#	client-sdks/platform/rust/openapi.json
#	client-sdks/platform/typescript/.speakeasy/gen.lock
#	client-sdks/platform/typescript/.speakeasy/gen.yaml
#	client-sdks/platform/typescript/README.md
#	client-sdks/platform/typescript/jsr.json
#	client-sdks/platform/typescript/package.json
#	client-sdks/platform/typescript/src/funcs/deploymentGroupsCreateFirstPartyDeploymentSession.ts
#	client-sdks/platform/typescript/src/funcs/deploymentsSetFirstPartyDeploymentInputs.ts
#	client-sdks/platform/typescript/src/lib/config.ts
#	client-sdks/platform/typescript/src/models/index.ts
#	client-sdks/platform/typescript/src/sdk/deployments.ts
The main-merge SDK regeneration bumped client-sdks/platform/typescript
eslint/@eslint/js to ^9.26.0 but the lockfile wasn't refreshed, breaking
`pnpm install --frozen-lockfile` in CI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The chart secret.yaml 'required' guardrail (management.token or
existingSecret) rejects partial values documents. Three manager_only
render tests passed None/partial values and began failing once the
guardrail landed. Render them with the manager-fetch overlay like their
siblings (aws now matches the existing gcp test).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Agent → manager reporting of self-update progress/failure so the platform can
show a truthful failed/in-progress state instead of inferring from a stalled
version — implements the 'update_failed' signal the self-update spec already
referenced.

- alien-core: SyncRequest.agent_update + AgentUpdateReport/AgentUpdatePhase
- alien-agent: status-aware upgrader reconcile — unique per-attempt Job names,
  Job/pod status reads, Pull/Apply classification, exponential backoff, warn on
  a failed Job, report construction; removes the blind 409=success stall
- alien-manager: thread agent_update through AgentSyncRequest + ReconcileData
- regenerate manager + platform OpenAPI / Rust + TS SDKs

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…-update-for-the-alien-agent-across-all-pull-mode-2

# Conflicts:
#	client-sdks/manager/openapi.json
#	client-sdks/manager/rust/openapi-3.0.json
#	client-sdks/platform/openapi.json
#	client-sdks/platform/rust/openapi-3.0.json
#	client-sdks/platform/rust/openapi.json
#	client-sdks/platform/typescript/.speakeasy/gen.lock
#	client-sdks/platform/typescript/.speakeasy/gen.yaml
#	client-sdks/platform/typescript/docs/models/deployment.md
#	client-sdks/platform/typescript/docs/models/deploymentdetailresponse.md
#	client-sdks/platform/typescript/docs/models/deploymentlistitemresponse.md
#	client-sdks/platform/typescript/docs/models/syncacquireresponseclusterendpointunion.md
#	client-sdks/platform/typescript/docs/models/syncacquireresponseconfig.md
#	client-sdks/platform/typescript/docs/models/syncacquireresponsedatabaseunion1.md
#	client-sdks/platform/typescript/docs/models/syncacquireresponsedatabaseunion2.md
#	client-sdks/platform/typescript/docs/models/syncacquireresponsedatabaseunion3.md
#	client-sdks/platform/typescript/docs/models/syncacquireresponsedatabaseunion4.md
#	client-sdks/platform/typescript/docs/models/syncacquireresponsedatabaseunion5.md
#	client-sdks/platform/typescript/docs/models/syncacquireresponseexternalbindingsunion7.md
#	client-sdks/platform/typescript/docs/models/syncacquireresponsehostunion1.md
#	client-sdks/platform/typescript/docs/models/syncacquireresponsehostunion2.md
#	client-sdks/platform/typescript/docs/models/syncacquireresponsehostunion3.md
#	client-sdks/platform/typescript/docs/models/syncacquireresponsehostunion4.md
#	client-sdks/platform/typescript/docs/models/syncacquireresponsepasswordsecretarnunion.md
#	client-sdks/platform/typescript/docs/models/syncacquireresponsepasswordsecretnameunion.md
#	client-sdks/platform/typescript/docs/models/syncacquireresponsepasswordsecreturiunion.md
#	client-sdks/platform/typescript/docs/models/syncacquireresponseportunion1.md
#	client-sdks/platform/typescript/docs/models/syncacquireresponseportunion2.md
#	client-sdks/platform/typescript/docs/models/syncacquireresponseportunion3.md
#	client-sdks/platform/typescript/docs/models/syncacquireresponseportunion4.md
#	client-sdks/platform/typescript/docs/models/syncacquireresponseportunion5.md
#	client-sdks/platform/typescript/docs/models/syncacquireresponseusernameunion1.md
#	client-sdks/platform/typescript/docs/models/syncacquireresponseusernameunion2.md
#	client-sdks/platform/typescript/docs/models/syncacquireresponseusernameunion3.md
#	client-sdks/platform/typescript/docs/models/syncacquireresponseusernameunion4.md
#	client-sdks/platform/typescript/docs/models/syncacquireresponseusernameunion5.md
#	client-sdks/platform/typescript/docs/models/synclistresponsedeployment.md
#	client-sdks/platform/typescript/docs/models/syncreconcilerequest.md
#	client-sdks/platform/typescript/docs/models/syncreconcileresponseclusterendpointunion.md
#	client-sdks/platform/typescript/docs/models/syncreconcileresponsedatabaseunion1.md
#	client-sdks/platform/typescript/docs/models/syncreconcileresponsedatabaseunion2.md
#	client-sdks/platform/typescript/docs/models/syncreconcileresponsedatabaseunion3.md
#	client-sdks/platform/typescript/docs/models/syncreconcileresponsedatabaseunion4.md
#	client-sdks/platform/typescript/docs/models/syncreconcileresponsedatabaseunion5.md
#	client-sdks/platform/typescript/docs/models/syncreconcileresponseexternalbindingsunion7.md
#	client-sdks/platform/typescript/docs/models/syncreconcileresponsehostunion1.md
#	client-sdks/platform/typescript/docs/models/syncreconcileresponsehostunion2.md
#	client-sdks/platform/typescript/docs/models/syncreconcileresponsehostunion3.md
#	client-sdks/platform/typescript/docs/models/syncreconcileresponsehostunion4.md
#	client-sdks/platform/typescript/docs/models/syncreconcileresponsepasswordsecretarnunion.md
#	client-sdks/platform/typescript/docs/models/syncreconcileresponsepasswordsecretnameunion.md
#	client-sdks/platform/typescript/docs/models/syncreconcileresponsepasswordsecreturiunion.md
#	client-sdks/platform/typescript/docs/models/syncreconcileresponseportunion1.md
#	client-sdks/platform/typescript/docs/models/syncreconcileresponseportunion2.md
#	client-sdks/platform/typescript/docs/models/syncreconcileresponseportunion3.md
#	client-sdks/platform/typescript/docs/models/syncreconcileresponseportunion4.md
#	client-sdks/platform/typescript/docs/models/syncreconcileresponseportunion5.md
#	client-sdks/platform/typescript/docs/models/syncreconcileresponseusernameunion1.md
#	client-sdks/platform/typescript/docs/models/syncreconcileresponseusernameunion2.md
#	client-sdks/platform/typescript/docs/models/syncreconcileresponseusernameunion3.md
#	client-sdks/platform/typescript/docs/models/syncreconcileresponseusernameunion4.md
#	client-sdks/platform/typescript/docs/models/syncreconcileresponseusernameunion5.md
#	client-sdks/platform/typescript/docs/models/targetconfig.md
#	client-sdks/platform/typescript/jsr.json
#	client-sdks/platform/typescript/package.json
#	client-sdks/platform/typescript/src/lib/config.ts
#	client-sdks/platform/typescript/src/models/createmanagerresponse.ts
#	client-sdks/platform/typescript/src/models/deployment.ts
#	client-sdks/platform/typescript/src/models/deploymentdetailresponse.ts
#	client-sdks/platform/typescript/src/models/managerretryresponse.ts
#	client-sdks/platform/typescript/src/models/operations/getresourcedeploymentdetail.ts
#	client-sdks/platform/typescript/src/models/persistimporteddeploymentrequest.ts
#	client-sdks/platform/typescript/src/models/syncacquireresponse.ts
#	client-sdks/platform/typescript/src/models/synclistresponse.ts
#	client-sdks/platform/typescript/src/models/syncreconcilerequest.ts
#	client-sdks/platform/typescript/src/models/syncreconcileresponse.ts
#	crates/alien-core/src/sync.rs
#	crates/alien-helm/src/generator.rs
#	crates/alien-helm/tests/generator/snapshots/generator__generator__helpers__data_layer.snap
#	crates/alien-helm/tests/generator/snapshots/generator__generator__helpers__manager_only_pure_worker.snap
#	crates/alien-manager/openapi.json
#	crates/alien-manager/src/loops/deployment.rs
#	crates/alien-manager/src/routes/sync.rs
#	crates/alien-manager/src/traits/deployment_store.rs
#	crates/alien-manager/src/transports/manager.rs
#	crates/alien-manager/tests/registry_proxy_cloud_test.rs
#	crates/alien-manager/tests/registry_proxy_test.rs
#	crates/alien-manager/tests/sqlite_store_tests.rs
#	crates/alien-operator/src/cli.rs
#	crates/alien-operator/src/db.rs
#	crates/alien-operator/src/lib.rs
#	crates/alien-operator/src/loops/agent_upgrade.rs
#	crates/alien-operator/src/loops/sync.rs
#	crates/alien-operator/src/otlp_server.rs
…-update-for-the-alien-agent-across-all-pull-mode-2
The platform API renamed agent→operator (self-update fields, target
version endpoint); regenerate the distributed platform SDK openapi so
the committed client matches.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A stale earlier lockfile-sync commit pinned client-sdks/platform/typescript
eslint specifiers to ^9.26.0, but the package.json is back to main's ^9.19.0,
breaking `pnpm install --frozen-lockfile` in CI Fast. No package.json differs
from origin/main, so main's lockfile is exactly correct here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
An earlier speakeasy regen committed a bloated TS SDK (+156k lines of
exploded numbered models, e.g. statuslifecycle18.ts), which OOMs tsc even
at 8GB heap in CI Fast. Nothing on this branch imports this SDK for
compile (managerx uses the Rust SDK; the dashboard has its own client),
so restore main's clean SDK + its openapi.json. The Rust SDK openapi keeps
the operator content managerx needs; a proper operator TS SDK regen is a
release-pipeline follow-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`cargo check` skips #[cfg(test)] modules, so a few test constructions kept
the old `regime` field after the rename: SyncRequest field access in
alien-core sync tests, and DeploymentRecord `regime: None` inits in three
alien-manager test helpers. cargo nextest compiles these, breaking CI Fast's
non-cloud rust tests. Verified all non-cloud test targets compile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Conform the OSS standalone manager to the packaging rename: the sqlite
`regime` column + sea_query `Regime` variant, the update-inventory params,
build_operator_target arg, a test fn name, and comments across manager/
operator/core.

Also fixes a latent bug the operator reconciliation left: the migrations
renamed the inventory columns to operator_* but the sea_query enum still
had Agent* variants (→ "agent_version"), so the inventory queries failed
at runtime ("no such column: agent_version"). Renamed the enum variants to
Operator* to match the columns. This is what broke CI Fast's non-cloud rust
tests (4 sqlite store tests). All 33 sqlite store tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mostly clean auto-merge. Manual resolutions:
- Platform SDK openapi (client-sdks/platform/rust/*): took main's newer
  version; regenerated with operator content during the platform merge.
- main added a new Platform::Machines enum variant — added the missing
  match arms in alien-test (config/distribution/e2e), grouping Machines
  with Kubernetes/Local/Test per the rest of the codebase.
- Fixed a latent bug the merge's readyz test caught: the otlp_server test
  helper ignored its first_sync_completed param and hardcoded `true`, so
  the /readyz readiness-gate test always saw ready. Pass the real param.

Compiles (all non-cloud targets); sqlite (33), sync (11), operator (49) pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the untyped operator_os/operator_arch strings with dedicated
OperatorOs / OperatorArch enums in alien-core::sync, and detect via
OperatorOs::detect() / OperatorArch::detect() in the operator.

Addresses the reviewer's "use an existing enum" note: the catalog's
`Architecture` spells arm as `arm64` (≠ the `aarch64` std::env::consts
reports) and `BinaryTarget` models buildable targets, not the host the
operator runs on — so neither round-trips cleanly. The enums serialize to
the exact std::env::consts spellings and match the platform's existing
operatorOs/operatorArch schema 1:1. Unsupported hosts report None.

The manager keeps its String request DTO (receives the serialized value),
so the change stays on the wire + operator detection.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Alon asked why we chose --atomic. Document the rationale inline: --atomic
rolls back a failed upgrade (that rollback is the Apply-phase failure we
report), --wait gates "done" on the new pod passing its readiness probe
(first successful /v1/sync), and --reuse-values keeps existing config while
only image.tag flips.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- min_supported_version was set to the target version, which (once the
  operator enforces it) would make every operator that actually needs the
  upgrade refuse it — the sub-target operators are exactly the ones told to
  upgrade. Use a real "1.0.0" floor; raise only for stepping-stone upgrades.

- is_plausible_semver split on the first of '-' or '+', so a valid pin with
  BOTH a pre-release and build suffix (1.4.0-rc.1+build.123) was rejected
  because '+' fell into the pre-release chunk. Peel '+build' first, then
  '-prerelease'. Added tests covering both suffixes and garbage input.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Yonatan Schkolnik and others added 23 commits July 8, 2026 16:36
The upgrade Jobs the operator spawns carried hardcoded brand into the
customer's cluster: app.kubernetes.io/managed-by="alien-agent" and
alien.dev/{component,target-version,attempt}. A white-labeled distribution
would leak "alien" there, and the labels didn't match the chart's
white-labeled resources.

Option A: stamp the standard app.kubernetes.io/* set instead
(name=operator, instance=<KUBERNETES_HELM_RELEASE> — the chart-injected
vendor identity, component=upgrader, managed-by=operator) on both the Job
and its pod template, and select on those standard labels. The operator's
own bookkeeping (attempt, target version) moves to un-prefixed keys
(upgrade-attempt / upgrade-target-version) — no branded domain. Also fixed
stale chart comments that named a hardcoded alien-agent-upgrader SA (it's
release-derived). New test asserts no alien.dev/alien-agent leaks.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The self-update module hand-rolled reqwest calls to the in-pod Kubernetes
API (build client + SA token + GET/POST) to create and list the upgrader
Jobs and list pods — the only place in the tree that talks to the K8s API
by hand.

Route it through alien-k8s-clients instead (JobApi::create_job/list_jobs,
PodApi::list_pods via KubernetesClient + try_incluster in-pod config), as
the reviewer asked. Auth/transport/error handling now come from the shared
client. The pure Job-body build + parse logic (and their tests) are kept,
bridged through JSON, so behavior is unchanged; the benign-409 race is
preserved via the error's http_status_code. New test guards that the
hand-built Job body stays schema-compatible with the typed k8s-openapi Job.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…-update-for-the-alien-agent-across-all-pull-mode-2
OS-agnostic core of the operator self-update flow for the os-service
packaging (the health-gated binary swap driven by a supervising
launcher), across four crates:

- alien-core: shared launcher<->operator handoff protocol in a new
  self_update module (pending/failure marker shapes, semver Version,
  atomic marker IO, shared retry backoff, env + exit-code contract);
  operator_target.binary is now per-host resolved {url, sha256,
  signature, minLauncherVersion} — one digest cannot cover an artifact
  map — and SyncRequest reports launcherVersion (reported, never
  driven: the launcher is frozen and only changes via redeploy)

- alien-launcher (new crate): platform-blind supervisor core — on-disk
  startup classification that recovers from a crash at any step,
  swap/promote/rollback state machine with a crash-injection test
  matrix, probation health gate, watchdog heartbeat thread that ticks
  through probation, crash-respawn backoff, and a blocking ureq
  /readyz probe. Platform shims (systemd/launchd/SCM) land next; a
  guard test keeps the core free of platform code

- alien-operator: binary self-update actuator (streamed download with
  sha256-while-streaming, disk preflight, staging, atomic pending.json,
  graceful exit with the handoff code; ed25519 verification behind the
  enforce-signature feature, failing closed on the placeholder key),
  marker-based failure reporting + exponential backoff,
  min_supported_version floor, /readyz extended to all five health
  conditions, ALIEN_HEALTH_ADDR bind override

- alien-manager: release-manifest loader (releases_url base; http,
  file://, or plain path; cached), per-host binary target emission
  gated on the reported launcher version vs minLauncherVersion
  ("redeploy required" when below), launcher_version inventory column,
  pin validation on the semver crate (rejects +build metadata and
  downgrades below the reported version), admin route renamed to
  PUT /v1/deployments/{id}/target-operator-version and registered in
  the OpenAPI doc

Generated artifacts refreshed via `pnpm run generate:manager-openapi`
and `pnpm run generate:manager-rust-sdk`. `generate:manager-api` (TS)
is blocked by a pre-existing spec validation issue (info.license.name
is empty), unrelated to this change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Wire the platform-blind launcher core to a real Linux service and prove
the whole os-service self-update flow end-to-end.

alien-launcher:
- Linux ServiceHost via sd-notify (READY/WATCHDOG/STOPPING, no-op without
  a notify socket; watchdog interval from WATCHDOG_USEC/3) with a
  signal-hook stop channel drained by poll_control.
- Unix ChildSupervisor (shared Linux/macOS) over command-group: spawns
  the operator in its own process group, sets PR_SET_PDEATHSIG on Linux so
  the operator dies with the launcher, graceful SIGTERM-then-SIGKILL stop.
- Unix VersionStore with real symlink pointers (atomic rename flips) and
  statvfs disk preflight; the full phase-0 state-machine suite
  (promote/rollback/classification/crash-injection) now runs parameterized
  against this real store, not just the stub.
- Synchronous main: flag parsing, platform selection, run loop; loud stub
  on non-Linux until those shims land. ureq built without TLS (loopback
  probe only) so the safety-net binary stays tiny and cross-compiles.

alien-deploy-cli:
- State-preserving launcher installer: idempotent version-store layout
  (state/ + secrets never clobbered, binaries always refreshed via
  temp+rename), Type=notify systemd unit from the doc-12 template, and
  `uninstall --purge`. Legacy direct-operator service kept behind
  --no-launcher (still used by `alien-deploy up`).

alien-operator:
- test-hooks feature: ALIEN_OPERATOR_FAKE_VERSION overrides the reported
  version for E2E, compile_error-guarded against release builds. Adds
  `operator --version`.

alien-manager:
- Surface launcher_version on the deployment read response (persisted +
  gated since phase 0 but never exposed) for the "redeploy required"
  signal. OpenAPI spec + Rust SDK regenerated.

alien-test:
- os-service E2E harness (real launcher + operator + in-process manager,
  artifacts served over HTTP, per-version wrapper-script identities) and
  7 scenarios: promote, rollback + backoff, digest mismatch, mid-probation
  crash recovery, die-with-parent, and the min-launcher gate. All green on
  Linux. TestManager gains a releases-url override.

ci: e2e-os-service.yml runs the launcher tests, the 7 scenarios, and a
systemd smoke on Linux (PR path filter + nightly).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The launcher signalled systemd READY=1 only after the operator passed
/readyz — which includes a successful manager sync. So if the manager
was unreachable at boot (e.g. a fleet reboot during a manager outage),
/readyz stayed 503, the Type=notify start timed out at 90s, and the
launcher service flapped under Restart=always. That contradicts the
"rollback != reachability" principle: a 503 caused by manager
unreachability is not a launch failure.

Split health by manager-dependence:
- /livez = the local conditions (DB open, lock held, deployment loop
  progressing) — "up and running, possibly just waiting for the
  manager." No longer an unconditional 200.
- /readyz = /livez + first-sync (the manager round-trip) — a strict
  superset, unchanged meaning.

The launcher now gates its startup READY on /livez (the supervisor is
operational once it has a live operator; a genuinely broken operator
that crashes or never serves /livez still fails the start), and keeps
the update probation gate on /readyz (a new version must prove full
health, including reaching the manager, before promote discards the
rollback target). This mirrors the Kubernetes livez/readyz split, with
the launcher playing the kubelet's role.

Also fix the CI systemd smoke: the stub operator now serves /livez, and
failures dump journalctl instead of a dead artifact-upload path.

All 7 os-service E2E scenarios pass on Linux; operator + launcher unit
tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Phase-0/1 added launcher_version to ReconcileData and an argument to
update_agent_metadata, but the manager's integration-test targets
(tests/*.rs) still used the old shapes and failed to compile. They were
missed because `cargo test -p alien-manager --lib` builds only unit
tests, not the tests/ targets. Fix the ReconcileData constructors and
update_agent_metadata calls, and assert launcher_version round-trips in
the full-inventory store test.

(Pre-existing alien-helm insta snapshot failures on this branch are
unrelated and untouched.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…launcherVersion

Add the operator `launcherVersion` field to the deployment and sync schemas in
the generated Alien Platform API OpenAPI spec (and the Rust SDK copies).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ith-parent

Phase 2 of the OS-service self-update: the launcher and operator now run on
macOS over the shared Unix code, with only the host shim differing.

- launcher: macOS launchd ServiceHost (signals + no-op notify), aliased with
  the shared Unix child supervisor + version store; run_supervisor enabled for
  macOS. All 51 launcher tests green on macOS with zero test forks.
- operator die-with-parent: replace PR_SET_PDEATHSIG (absent on macOS) with a
  getppid() watch. Runs on a DEDICATED OS THREAD, not a tokio task — a tokio
  timer is starved under load and left orphans alive for minutes; a std::thread
  sleep is immune. 1s interval; InstanceLock remains the hard backstop.
- healthy_reset: extract the crash-backoff reset into a pure, unit-tested
  helper and remove dead StubChild scaffolding it was meant for.
- e2e: the os-service suite now runs on macOS too (6/6 green); orphans reap in
  <=2s at full parallelism after the OS-thread fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…et floor

When an admin pins an os-service operator target whose release manifest needs a
newer launcher than the frozen one installed, the manager withholds the target.
Previously this was silent; now the manager computes the verdict and forwards it
on the reconcile so the platform can show it.

- release_manifest: extract check_launcher_floor() -> {Ok | RedeployRequired |
  InvalidFloor} as one pure helper shared by the target gate and the verdict.
- sync route: resolve_redeploy_status() maps the floor check to
  (redeploy_required, min_launcher_version), inert for kubernetes / no pin /
  already-converged / manifest error; computed in agent_sync and threaded
  through two new ReconcileData fields on both os-service reconcile paths.
- refactor binary_operator_target to use the shared helper.
- regenerate the platform OpenAPI spec + Rust SDK schema for the two new
  reconcile fields.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
An operator self-update handoff (or any operator exit) orphaned the user
workload spawned by alien-runtime: the operator never told its worker
runtime to stop, and the app child had no die-with-parent, so it was
reparented to init and a second copy started under the swapped-in
operator.

Tear it down on both paths:
- alien-operator: call the local worker manager's shutdown_all() during
  graceful shutdown, so the worker runtime kills the app child.
- alien-runtime: spawn the app with kill_on_drop(true) and, on Linux,
  PR_SET_PDEATHSIG=SIGKILL, as crash backstops.

macOS keeps kill_on_drop + graceful teardown (no pdeathsig); Windows is
tracked to use a kill-on-close Job Object.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
alien-deploy deploy hardcoded no_launcher: true, so link-onboarded
deployments always got the legacy direct-operator service and could
never self-update, even with updates = auto. Default to the launcher
layout (matching operator install); use_launcher_layout_for still gates
it to Linux, and a new --no-launcher flag keeps the direct service as an
escape hatch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Deploy a real workload (alien-test-app built to a local OCI), capture the
operator's app child, pin a new operator version, and assert that after
the swap exactly one app remains and none was reparented to init. Adds
rig helpers (deploy_test_app_workload, wait_for_status, app_pids,
is_orphaned) and installs zig + cargo-zigbuild in the CI job for the
workload's musl OCI build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CI showed the appless test deployment stays `pending`, so create_release's
status-gated auto-assign can't attach the workload (the operator gets no
app to spawn). Documented the concrete fix (seed the release before the
deployment via a start_with_workload() variant) and marked the test
#[ignore] so the suite stays green while the harness plumbing is finished.
The fix it guards (shutdown_all + kill_on_drop/PR_SET_PDEATHSIG) is verified
live.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…han guard

The appless test deployment stayed `pending`, so create_release's
status-gated auto-assign couldn't attach the workload. Publish the
alien-test-app release BEFORE the deployment is created (new
start_with_workload rig variant) so create_deployment attaches it directly
via get_latest_release; the operator then spawns the app. Removes the
#[ignore] and the wait_for_status / deploy_test_app_workload dance.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Implement WindowsHost, the ServiceHost boundary over the Service Control
Manager. Control signals (Stop/Shutdown) arrive on a control cell drained
by poll_control; report_ready/report_stopping/heartbeat drive the SCM
status (Running / StopPending / checkpoint-while-pending, which the SCM
ignores once Running). A --console Ctrl-C path (driven by the E2E suite)
makes the status calls no-ops. Pure map_service_control + unit test.

Adds windows-service/windows-sys under cfg(windows). The main.rs SCM
dispatch (service_dispatcher -> core::run) and the ActiveHost alias land
with T3.2 (Job-Object ChildSupervisor) and T3.4 (junction VersionStore),
which core::run needs; until then the module is allow(dead_code).

Cross-compiles clean for x86_64-pc-windows-gnu; host build + 52 launcher
tests unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Spawn the operator in its own Job Object (`command-group`, kill-on-close =
die-with-parent) and stop it with CTRL_BREAK to its process group, escalating
to Job termination after the grace window. Mirrors `unix_child.rs`. Three
`#[cfg(windows)]` real-process tests (stop escalation, exit-code passthrough,
kill-on-close) run in the T3.7 Windows CI; here they are cross-compile-verified.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…hase 3 T3.3

Two die-with-parent gaps for the workload the operator spawns via alien-runtime:

- Windows has no pdeathsig, so `AppChild` (a new per-OS wrapper) places the app
  in its own kill-on-close Job Object via `command-group`; the app dies when the
  operator exits or crashes. Linux keeps `PR_SET_PDEATHSIG`; macOS keeps
  `kill_on_drop`. The wrapper hides the per-OS child type behind one API so the
  runtime handles a single child.
- The operator, as a launcher child on Windows, now honors CTRL_BREAK (the
  launcher's graceful-stop signal) alongside CTRL_C — draining the InstanceLock,
  DB, and app child. The `--service` (no-launcher SCM) path is unchanged.

Also gate the operator's `Duration` import `#[cfg(unix)]` (only the Unix
parent-death watch uses it), removing an unused-import warning on Windows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…hase 3 T3.4

`current`/`last-stable` are directory junctions to `versions\<v>`. The flip is
non-atomic (Windows can't rename over a dir): create `.new` -> remove -> rename.
`current()` reconciles any crash residue on the next start — both-present or
mid-rename finish the flip; a missing `current` is reconstructed from
`probation.new` (else `last-stable`), logged loudly. `gc` tolerates a locked
binary (a just-swapped-away operator .exe) by scheduling reboot-delete
(MoveFileExW) instead of failing; free-space preflight via GetDiskFreeSpaceExW.
Everything non-pointer delegates to `store_common`, identical to the Unix store.

Tests (cross-compile-verified; run in T3.7 Windows CI): flip storm, all five
crash-residue cases, locked-binary gc, and the full Phase-0 state-machine suite
via `TestStoreOps` — zero suite edits. The Unix concurrent-reader test has no
Windows analogue on purpose: the junction flip is non-atomic and the launcher
reads/flips on one thread, so the residue tests cover interruption instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…se 3 T3.5

`operator install` now uses the launcher-supervised layout on Windows too
(`use_launcher_layout_for` → linux|windows; `--no-launcher` still forces legacy).

- The layout is cross-platform: `current`/`last-stable` are directory junctions
  on Windows (absolute targets), and binary names use `EXE_SUFFIX` (Linux stays
  byte-identical; Windows gets `alien-operator.exe` / `alien-launcher.exe`).
- `install_launcher_service` refactored to a shared stop + `write_layout`, then a
  per-OS `register_and_start`: systemd on Linux, SCM on Windows. Windows
  registers `alien-launcher.exe` via service-manager (`sc create`) with the
  operator config passed as the service environment, then applies the doc-12
  recovery config via `sc.exe failure` (reset 24h, three 5s restarts) —
  service-manager can't set restart policy through `sc create`, so it passes
  `RestartPolicy::Never` and `sc failure` is the real config.
- Uninstall is unchanged (label-based); `--purge` still removes the data dir.

Tests: decision matrix (windows→true), a doc-12 `sc failure` golden, and a
Windows layout-idempotency test (junctions + `.exe`, mirrors the Unix one).
Host tests pass; the Windows path is cross-compile-verified (runs in T3.7).

Note: the launcher's Windows run_supervisor must set operator_binary to
`alien-operator.exe` (EXE_SUFFIX) to match what this installer writes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… T3.1

Completes T3.1 — main.rs now runs the launcher as a real Windows service.

- SCM mode: main() parks the parsed Args and hands control to service_dispatcher;
  service_main constructs WindowsHost::service(), runs the core loop, and reports
  the stop status (a nonzero code trips the doc-12 SCM recovery config, the
  Windows analogue of systemd respawning on a nonzero exit).
- Console mode (--console): bypasses the dispatcher with WindowsHost::console()
  (Ctrl-C handler) and runs the core loop directly — what the E2E suite drives.
- platform/mod.rs aliases WindowsChildSupervisor/WindowsVersionStore as
  ActiveChildSupervisor/ActiveVersionStore; the host is named directly since its
  service()/console() constructors differ from the Unix new().
- operator_binary uses EXE_SUFFIX (alien-operator.exe on Windows) to match what
  the T3.5 installer writes.
- Drops the three #![allow(dead_code)] now that the shims are live; report_stopped
  takes an exit code for the SCM failure->recovery path.

--console is a flag (not a `run` subcommand) — consistent with the flag-only CLI
and the T3.5 service binPath. Cross-compile-verified; real SCM/console behavior
runs in the T3.7 Windows CI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…hase 3 T3.7

Add windows-os-service to e2e-os-service.yml (depot-windows-2025-16):
- Build the launcher; run `cargo test -p alien-launcher -p alien-operator` on
  native Windows — exercising the junction VersionStore (flip + all five
  crash-residue cases + locked-file gc), the full Phase-0 state-machine suite
  over it, the Job-Object kill-on-close ChildSupervisor, and the SCM control
  mapping.
- SCM smoke: sc create the launcher as a service, apply + assert the doc-12
  recovery config (sc failure / qfailure), start -> poll RUNNING (a compiled Rust
  stub operator answers /livez so the launcher gates ready), stop -> poll STOPPED
  (the stop propagates through the launcher to the operator), delete.

Covers phase exit criteria 1/2/4 and the SCM-smoke half of 3. The full
seven-scenario E2E is deferred with the os_service rig port (the rig is
Unix-only: symlinks, /bin/sh version wrappers, pgrep/ps/kill, a musl workload).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ast-fail

`not_ready_on_connection_refused` required a refused connection to resolve in
<1s — a Unix property (the OS RSTs immediately). On Windows a connect to a
just-released loopback port runs to the connect timeout before failing, so the
new Windows CI (T3.7) tripped on it (took ~2.0s). Assert the probe's real
contract instead: a refused connection resolves to not-ready within
`timeout_global` (+1s slack) — holds cross-platform, still catches a
never-returns regression. Behaviour (`!is_ready`) is unchanged and already
passed on Windows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@yonatan-alien yonatan-alien changed the title feat: K8s agent self-update — chart guardrails, /v1/rejoin recovery, persistence default feat: agent self-update across all pull modes — K8s (Helm) + OS-service launcher (Linux/macOS/Windows) Jul 11, 2026
Yonatan Schkolnik and others added 3 commits July 11, 2026 19:46
…h overlay

The chart's secret.yaml guardrail (`required` management.token or existingSecret)
rejects partial values documents. `log_collector_enabled_chart_lints_and_templates`
passed logCollector-only values and was missed in f6db604, which fixed the same
guardrail failure for the manager_only render tests. Render it with the standard
manager-fetch overlay like its siblings. `alien-helm --lib` is 15/15 green again.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Conflicts resolved:
- alien-manager/routes/deployments.rs: keep our set_target_operator_version
  route + validate_target_pin; fuse both `#[cfg(test)] mod tests` (our
  target-pin tests + main's stack-settings/machines tests) under one
  `use super::*`.
- alien-operator/cli.rs: union the import — ours keeps ContextError, main adds
  ValueEnum (for its new InitialDesiredReleaseArg).
- alien-test/config.rs: identical logic, took main's multi-line formatting.
- client-sdks/platform (openapi specs + generated SDK): took THEIRS. Main's
  #120/#121 added platform types (NewDeploymentRequestInitialDesiredRelease,
  KubernetesClusterSource, DeploymentSetupConfigPublicSubdomain, ...) that the
  auto-merged alien-cli source now depends on, so the previous "keep ours" no
  longer compiles. The branch's platform-schema additions (rejoin /
  target-agent-version) must be re-added by a platform-side openapi regen.

Silent semantic merge fixed (auto-merged textually, didn't compile):
- alien-manager sqlite/command_registry.rs test fixture: added the 7 new
  DeploymentRecord fields (our launcher_version + main's operator_arch /
  operator_os / operator_image_repository / operator_version / packaging /
  target_operator_version).

Verified: ci-fast-equivalent `cargo check --workspace --tests` clean (excluding
the cloud-client crates ci-fast excludes).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ker snapshots

The chart values gained the self-update `upgrade:` block, /livez+/readyz probes,
persistence-enabled-by-default, and generated-encryption-key defaults, but these
two insta snapshots were never regenerated — CI Fast has been red on them since
before this branch's Windows/merge work. Accept the current (correct) rendered
output. `cargo test -p alien-helm` is green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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