Fleet Autonomy Control Plane (OS-5.24 — AU-OS.config.health-gated-deploy-rollback, OS-5.29)¶
The Tranche-3 autonomy build: the layer that lets the platform act on its fleet — restart, scale, deploy, remediate — without ever acting outside policy. Five pieces, one decision point.
Evidence boundary (AU-SCALE): The AU source path is
IMPLEMENTED: the built-inDryRunActuator,DockerActuator, and optionalKubernetesActuatorexist, and the Kubernetes path is selected withFLEET_ACTUATOR=k8s(orkubernetes) whenkubectlis available. The shipped default remainsdryrun, and every mutating action remains policy-gated. The committed Kubernetes manifests areIMPLEMENTEDreference/staged inputs, not proof of a deployable render, a live cluster, or the one-million-resident campaign. See the AU scale claim register for the source anchors and evidence vocabulary.
flowchart LR
subgraph Ingress
AM[Alertmanager / Uptime Kuma / Portainer] -->|POST /api/fleet/events| FE[FleetEvent KG nodes\nOS-5.15]
end
FE --> TR[fleet_event_triage task]
TR --> PB[Remediation playbooks\nOS-5.26]
REG[deploy/mcp-fleet.registry.yml\n+ desired-state override] --> RC[Fleet reconciler tick\nOS-5.25]
REG -->|scaling: bounds| AS[Autoscaler tick\nOS-5.29]
SIG[Bulk ScalingSignalProvider\nlocal gauges / Prometheus / injected] --> AS
SIG -->|one bounded batch\nclient + unique queries + deadline| PROM[Prometheus]
OBS[FleetObserver\nKG events + docker / injected] --> RC
OBS --> AS
OBS --> PB
FH[FleetHealthEvidence\nfleet.health.v1] -->|healthy only| RC
FH -->|healthy only| AS
PB --> AP{ActionPolicy\nOS-5.24}
RC --> AP
AS --> AP
DW[Deploy watch\nOS-5.27] --> AP
AP -->|allow / allow+notify| ACT["FleetActuator\ndry-run default / docker / kubectl(k8s) / injected"]
AP -->|queue_approval| APQ[ActionApproval nodes\n/api/fleet/approvals]
AP -->|deny| AUD
APQ -->|granted| RC
ACT -->|restart/deploy| DW
AP --> AUD[ActionDecision audit ledger]
ACT --> EXE[ActionExecution records]
OS-5.24 — ActionPolicy: the single autonomy decision point¶
orchestration/action_policy.py. Every autonomous mutating operational
action — reconciler convergence, playbook restarts, deploy-watch rollbacks —
passes through ActionPolicy.decide(ActionRequest) first. No caller
actuates without a decision.
Before this layer, every autonomy gate was a binary env flag (on = fully autonomous, off = dead). ActionPolicy replaces the cliff with per-action tiers and budgets:
| Concept | Meaning |
|---|---|
| tier | auto | auto_notify | approval_required | forbidden |
| rate limit | max allowed executions per action+target per window → exceeded = deny (a looping remediation is broken) |
| blast radius | max distinct targets per action kind per window → exceeded = queue for approval (a wide wave needs a human) |
| maintenance window | "HH:MM-HH:MM" UTC; auto tiers outside the window are queued for approval |
Policy sources, most-specific first:
- KG overrides —
governance_rulenodes withscope: action_policy(flat props or arule_jsonpayload;active: falsedisables). These always beat file rules and can be written at runtime. - YAML file —
ACTION_POLICY_PATH, default = the shipped conservativedeploy/action-policy.default.yml(embedded byte-identically in code for installed wheels): everything mutating isapproval_required; only no-op/diagnostic kinds areauto.
Example policy:
version: 1
defaults:
tier: approval_required
rate_limit: {max: 3, window_s: 3600}
blast_radius: {max_targets: 3, window_s: 3600}
rules:
- {kind: diagnose, target: "*", tier: auto}
- kind: restart_service
target: "staging-*"
tier: auto_notify
rate_limit: {max: 2, window_s: 1800}
maintenance_window: "02:00-05:00"
- {kind: restart_service, target: "*", tier: approval_required}
- {kind: scale_service, target: "prod-db", tier: forbidden}
Decisions are allow / allow_notify / queue_approval / deny; internal
errors fail closed (deny). Every decision is audit-logged as an
ActionDecision KG node — which is also the durable ledger the rate/blast
accounting reads, so budgets hold across processes and restarts.
Approval flow. queue_approval files an
ActionApproval node (deduped per kind+target while pending). The existing
fleet approvals routes carry it end-to-end: GET /api/fleet/approvals lists
it, POST /api/fleet/approvals/grant
(job_id = the action_approval:* id) stamps it, and the reconciler's
approved-action drain creates or releases the authorized WorkItem on the next
tick. Approval evidence never doubles as executable work state.
Relation to AHE-3.20. The promotion-governance validator
(knowledge_graph/research/promotion_governance.py) remains the
code-evolution gate; it was the conceptual template (typed verdicts,
constitution rules). The adoption follow-up is now closed:
GovernedAutoMerger (knowledge_graph/research/auto_merge.py) routes its
own promotion decision through a merge_promotion ActionRequest before
the proposal→active lifecycle flip — deny blocks the promotion (recorded
and audited, fail-closed on policy errors), the shipped approval_required
tier queues the same ActionApproval the AHE-3.21 publication bridge
consumes (deduped per kind+target), and auto/auto_notify tiers proceed.
See Autonomous Evolution.
AU-OS.config.desired-state-fleet-reconciler — Desired-state fleet reconciler¶
orchestration/fleet_reconciler.py, registered as the leader-only
fleet_reconciler maintenance tick (opt-in: FLEET_RECONCILER=1, interval
FLEET_RECONCILER_INTERVAL).
- Desired state —
deploy/mcp-fleet.registry.yml(override path:FLEET_REGISTRY_PATH): every listed service should berunning, 1 replica unless stated. An optionalFLEET_DESIRED_STATE_PATHYAML layers per-servicereplicas/desired: running|stopped/version. - Observed state — the
FleetObserverprotocol (orchestration/fleet_observation.py). Default =KGFleetObserver(folds the AU-OS.config.fleet-event-ingress FleetEvent stream into per-service status + flap counts) composed withDockerFleetObserverwhen a docker CLI exists. Deployments inject richer observers (Portainer, Prometheus) viaset_fleet_observer(). - Divergence rules (conservative) — observed down ⇒
restart_service; replica mismatch ⇒scale_service; up-but-desired-stopped ⇒stop_service; no observation ⇒ no action (never act on zero evidence). - Supervisory evidence gate — every tick reads the shared
FleetHealthEvidencecontract (orchestration/fleet_health.py). Onlystatus: healthypermits desired-state convergence.partial,degraded, orunavailablereturns a typed not-ready report and skips proposals, approval draining, and actuation; an unreadable authority is never treated as an empty observed fleet. - Every proposal passes ActionPolicy, then the
FleetActuatorprotocol (orchestration/fleet_actuation.py). The defaultDryRunActuatorrecords intent (ActionExecutionnodes,dry_run: true) and mutates nothing — safe to enable fleet-wide before any real actuator is wired.FLEET_ACTUATOR=dockerselects the reference docker CLI actuator;FLEET_ACTUATOR=k8s(orkubernetes) selects the referenceKubernetesActuatoroverkubectlwhen that CLI is available, targeting a Deployment inFLEET_ACTUATOR_K8S_NAMESPACE(defaultplatform). If the CLI is unavailable, selection fails safe to dry-run. Portainer/Swarm actuation remains deployment-wired viaset_fleet_actuator(). Selecting an actuator is source/config evidence only; it does not assert that a cluster is reachable or that a deployment has completed. - Restart/deploy executions schedule an AU-OS.config.health-gated-deploy-rollback health watch; granted
ActionApprovalentries are drained and executed each tick; a storm guard caps actions per tick (FLEET_RECONCILER_MAX_ACTIONS); each pass writes aReconcileReportnode.
AU-OS.host.remediation-playbooks — Remediation playbooks (on the AU-OS.config.fleet-event-ingress seam)¶
knowledge_graph/adaptation/remediation_playbooks.py, registered through
the existing register_playbook() seam for critical/error events from
alertmanager / uptime-kuma / portainer / generic (warnings keep the AU-OS.config.fleet-event-ingress
default playbook). The dispatcher still runs the default playbook first, so
correlation + failure_gap golden-loop escalation are preserved.
| Playbook | Steps |
|---|---|
service_down |
confirm via observer (recovered ⇒ stop) → ActionPolicy → actuate restart → schedule AU-OS.config.health-gated-deploy-rollback verification watch → escalate on deny/failure |
service_flapping |
≥3 down-events in the observer window ⇒ back off (no restart even on an auto tier) + escalate a restart proposal to a human |
resource_pressure |
disk/memory/CPU markers ⇒ never auto-act: notify + queue an investigate_resource_pressure proposal |
Every step outcome is appended to the originating FleetEvent node
(remediation_log JSON trail + remediation_status). Escalation = an
approval-queue entry plus a notification through the KG-2.42 notifier
seam (actions/dispatch.send_notification — journaled by default; a
deployment registers a real Slack/email Notifier via
set_default_notifier).
AU-OS.config.health-gated-deploy-rollback — Health-gated deploy + rollback¶
orchestration/deploy_watch.py — the safety net every mutating action ends
in. watch_deploy(engine, service, version, window_s, ...) enqueues a
durable deploy_watch WorkItem; the worker-side run_deploy_watch probes the
FleetObserver every DEPLOY_WATCH_POLL seconds until the recorded
deadline (a watch whose expired native lease is reclaimed after a host crash
resumes its original window):
- any
downobservation ⇒ failed →on_fail(default: arollback_servicerequest through ActionPolicy — queued under the shipped default, actuated under permissive policy — plus operator escalation); - window closes with ≥1 healthy and 0 down probes ⇒ success;
- zero observations ⇒ unobserved: notify (monitoring gap), never roll back on zero evidence.
Outcomes persist as DeployWatch nodes. The reconciler and the
service_down playbook schedule a watch after every successful
restart/deploy actuation.
OS-5.29 — Reactive replica autoscaling¶
orchestration/fleet_autoscaler.py, registered as the leader-only
fleet_autoscaler maintenance tick (opt-in: FLEET_AUTOSCALER=1, interval
FLEET_AUTOSCALER_INTERVAL, default 60 s). Where the reconciler converges on
declared replica counts, the autoscaler chooses them from load — the
smallest viable autoscaler, composed from the existing primitives.
Bounds — registry-declared. A service is only ever autoscaled when its
registry/override entry carries a scaling: block:
services:
- name: vector-mcp
scaling:
min: 1 # replica floor (>= 0; default 1)
max: 3 # replica ceiling (required; >= min)
signal: queue_depth # queue_depth | consumer_lag | cpu | custom metric
target: 200 # per-replica target value (required; > 0)
scale_up_step: 1 # max replicas added per evaluation (default 1)
scale_down_step: 1 # max replicas removed per evaluation (default 1)
cooldown_s: 300 # min seconds between scale actions (default 300)
deadband: 0.05 # relative target band; 5% by default
scale_up_stabilization_samples: 1 # fast scale-up default
scale_down_stabilization_samples: 3 # conservative scale-down default
Because deploy/mcp-fleet.registry.yml is machine-generated, the normal home
for scaling blocks is the FLEET_DESIRED_STATE_PATH override (same shape,
layered on top; scaling: null explicitly disables). Validation is strict —
max >= min >= 0, signal/target/max explicit, and all numeric values are
finite and bounded — and an invalid block is
dropped with a warning (the service keeps the static AU-OS.config.desired-state-fleet-reconciler reconcile);
a typo never produces surprise scaling.
Signal hierarchy — pluggable, no hard Prometheus dependency
(orchestration/scaling_signals.py, ScalingSignalProvider plus its optional
bounded signal_values(requests) extension). Every autoscaler tick submits one
deduplicated batch of requested service/signal pairs. Every accepted sample is
immutable and carries value, source, service, symbolic signal,
aggregation (fleet_total or per_replica), explicit unit, explicit
tenant/fleet scope, and observed_at. The autoscaler rejects malformed,
stale, future-skewed, replayed, cross-service, cross-aggregation, cross-unit,
and cross-scope samples; invalid or missing data is never interpreted as a
scale-down signal:
- Injected —
set_scaling_signal_provider(MyProvider())(lgtm-mcp, Thanos, pushgateway, …) wins over everything. - Prometheus —
SCALING_PROMETHEUS_URLset ⇒ one bounded instant-query batch against/api/v1/query(one reusedhttpxclient only for a thread-safe transport, otherwise one client per worker; all queries run in rolling waves, at most 64 unique allowlisted queries, 4 process-wide in-flight requests, and a 10-second overall deadline). The four fixed workers are daemon threads, so an injected transport that ignores timeout and cancellation consumes bounded capacity but cannot hang process exit. Well-known signals and explicitly deployment-injected definitions are allowlisted symbolic names; caller-supplied raw PromQL is not accepted. Query length, per-query timeout, result-series cardinality, unique-query count, concurrency, overall deadline, and sample age are bounded. - Local (default, zero-infra) — this process's own AU-OS.observability.no-op-without-metrics/KG-2.55
value/observed-at gauge pairs:
queue_depth→agent_utilities_kg_ingest_queue_depthplus its_observed_atcompanion,consumer_lag→agent_utilities_kg_ingest_consumer_lagplus its companion; other names are available only through an explicitly deployment-injected allowlist and, for per-replica signals, a required service label.
The shipped queue-depth and consumer-lag definitions are truthfully declared in
items and messages units with fleet scope and bind exactly to the
kg-ingest-worker scale unit; CPU is percent with service scope. Other
services return no data until deployment supplies an immutable exact
service/scale-unit definition (and the same binding is used by the Prometheus
definition); a provider never relabels a global series with the caller's
requested service. Definitions and samples must match unit and scope exactly at
consumption. Local paired gauges are snapshotted under one lock, so a reader
cannot combine a new value with an old timestamp. Providers without the bulk
extension may fall back to bounded single reads only when explicitly marked
trusted in-process; remote providers without the extension fail closed rather
than causing one timeout per service.
None (unknown signal, provider failure, metrics extra absent, empty query
result, stale/replayed sample, malformed value, missing/mismatched pair, or
high-cardinality response)
means no data ⇒ no scaling action — the same
never-act-on-zero-evidence rule the reconciler applies to unobserved
services.
Before reading desired state, observations, or signals, the autoscaler also
requires FleetHealthEvidence.autoscaling_ready. Any partial, degraded,
or unavailable supervisory dependency produces zero evaluated actions with
the evidence envelope attached; it is not a zero-load signal and cannot
converge replicas while the control plane is unreadable.
Target tracking (as shipped):
with effective_current = max(current, 1) (scale-from-zero works) and
fleet-total signals (queue_depth, consumer_lag) first normalized to
per-replica (value / effective_current); cpu and custom signals are
treated as per-replica averages already. Multiple fleet-total series are
summed; multiple per-replica series are averaged. Deployment-injected definitions state
their aggregation explicitly; the consumer verifies that metadata before
target tracking. The result is clamped to [min, max] and step-capped (at most
scale_up_step added / scale_down_step removed per evaluation), and
current comes from the FleetObserver — a service with no replica
observation (or observed down: that is the reconciler's restart problem)
is skipped.
Cooldown / flap rules. No scale action — in either direction — within
cooldown_s of the service's last allowed/executed scale_service entry in
the durable ActionDecision/ActionExecution ledger (shared across
processes and restarts). That single rule is also the flap guard: an
up-then-down oscillation inside the window is impossible. The per-tick
action budget reuses FLEET_RECONCILER_MAX_ACTIONS.
Deadband / stabilization. deadband is a finite relative band around the
per-replica target; values within it reset any direction streak and hold the
current replica count. Outside the band, scale-up requires the declared number
of consecutive fresh samples (default 1), while scale-down requires the
declared conservative count (default 3). A missing, stale, ambiguous,
cross-unit/scope, or replayed sample resets the direction state and can never
authorize a scale-down. A direction change also resets the streak before the
new direction can act. After an actual successful scale action, the consumed
direction streak is reset; the next action must collect its full declared
consecutive-sample count again.
Gate → actuate → watch. Every proposal is a scale_service
ActionRequest (source: autoscaler, params carry
replicas/from_replicas/direction/signal/value/target) through ActionPolicy
— approval_required under the shipped default policy, so enabling
FLEET_AUTOSCALER by default only queues scale proposals for a human.
Allowed actions run through the FleetActuator seam (dry-run, Docker, optional
Kubernetes, or an injected deployment actuator); a successful scale-up
schedules an AU-OS.config.health-gated-deploy-rollback deploy watch, and scale-downs do too when the policy
file opts in:
Audit. At most ONE compact AutoscaleEvaluation node per tick (per-tick
verdict list in details_json; quiet ticks write nothing) — per-action
audit already lives in the ActionDecision/ActionExecution ledger.
Reactive push (KG-2.253). The slow fleet_autoscaler interval above is the
safety-net reconcile; the push half reacts to the change-event itself. A
fast (_AUTOSCALE_REACTIVE_INTERVAL, 5 s) fleet_autoscale_reactive tick polls a
durable engine change-feed subscription over control-plane WorkItem mutations —
fleet_autoscale_subscription(engine), built on the one
agent_utilities.graph.reactive.subscribe primitive (the engine's native
CDC/watch, engine concepts EG-KG.query.streaming-cdc-subscriptions/230). The poll is cheap and non-blocking
(O(new WorkItem changes), not a full re-scan) and short-circuits when nothing
changed, so it can run far more often than the metric-poll interval. When the
engine pushes a queue-depth-moving WorkItem change, it runs ONE autoscale_fleet
pass at change-time instead of waiting out the interval. Same FLEET_AUTOSCALER
opt-in gate; a no-op (the periodic tick still covers it) when the engine exposes
no streaming surface.
flowchart LR
TASK["WorkItem enqueued / claimed\n(__control__)"] -->|engine CDC commit| FEED["engine change-feed\n(watch/cdc_read)"]
FEED -->|push| SUB["EngineSubscription\n(KG-2.253)"]
SUB -->|pending| RX["_tick_fleet_autoscale_reactive\n(5s, cheap poll)"]
RX --> EVAL["autoscale_fleet → ActionPolicy → actuator"]
TIMER["_tick_fleet_autoscaler\n(60s safety net)"] --> EVAL
Strangled: capabilities/auto_healing.py¶
The dormant AutoHealingEngine shell (disabled by default, never-wired
skill_evolver/fallback_router hooks, no production caller) is removed.
Its one useful bit — threshold-counted repeated-failure escalation — is
absorbed into graph/parallel_engine.py
(_escalate_repeated_failure), which now files a failure_gap Concept
topic through the live AU-AHE.harness.failure-evolution propose-only remediation chain at the same
3-failure threshold.
Flags¶
| Flag | Default | Concept |
|---|---|---|
ACTION_POLICY_PATH |
shipped conservative default | OS-5.24 |
FLEET_RECONCILER |
false (opt-in) |
AU-OS.config.desired-state-fleet-reconciler |
FLEET_RECONCILER_INTERVAL |
120 s |
AU-OS.config.desired-state-fleet-reconciler |
FLEET_RECONCILER_MAX_ACTIONS |
5 / tick |
AU-OS.config.desired-state-fleet-reconciler |
FLEET_REGISTRY_PATH |
deploy/mcp-fleet.registry.yml |
AU-OS.config.desired-state-fleet-reconciler |
FLEET_DESIRED_STATE_PATH |
unset | AU-OS.config.desired-state-fleet-reconciler |
FLEET_ACTUATOR |
dryrun (docker, k8s/kubernetes optional) |
AU-OS.config.desired-state-fleet-reconciler |
FLEET_ACTUATOR_K8S_NAMESPACE |
platform |
AU-OS.config.desired-state-fleet-reconciler |
DEPLOY_WATCH_WINDOW |
300 s |
AU-OS.config.health-gated-deploy-rollback |
DEPLOY_WATCH_POLL |
15 s |
AU-OS.config.health-gated-deploy-rollback |
FLEET_AUTOSCALER |
false (opt-in) |
OS-5.29 |
FLEET_AUTOSCALER_INTERVAL |
60 s |
OS-5.29 |
SCALING_PROMETHEUS_URL |
unset (local gauges) | OS-5.29 |
All typed AgentConfig fields (see docs/architecture/configuration.md).
Wiring a real deployment¶
from agent_utilities.orchestration.fleet_actuation import set_fleet_actuator
from agent_utilities.orchestration.fleet_observation import set_fleet_observer
from agent_utilities.orchestration.scaling_signals import set_scaling_signal_provider
from agent_utilities.knowledge_graph.actions.dispatch import set_default_notifier
set_fleet_observer(MyPortainerObserver()) # richer observed state
set_fleet_actuator(MyPortainerActuator()) # real actuation
set_scaling_signal_provider(MyLgtmProvider()) # richer load signals (optional)
set_default_notifier(MySlackNotifier()) # real escalation channel
For autoscaling without a custom provider, point the built-in at the operator's
Prometheus (SCALING_PROMETHEUS_URL=http://prometheus:9090), declare
scaling: bounds in the FLEET_DESIRED_STATE_PATH override, and set
FLEET_AUTOSCALER=1.
Then set FLEET_RECONCILER=1 and relax ACTION_POLICY_PATH rule-by-rule
(staging targets first) as confidence grows — the audit ledger
(ActionDecision / ActionExecution / ReconcileReport / DeployWatch /
AutoscaleEvaluation nodes) is the evidence trail. A staging or rendered
manifest remains IMPLEMENTED/LAB-PROVEN only at the evidence level it has
actually passed; record a LIVE claim only with exact artifact, configuration,
health, and timestamp evidence, and reserve 1M-CERTIFIED for the signed
one-million-resident campaign.