Skip to content

Complete agent reconciliation and fleet revision status - #35

Merged
artemnikitin merged 20 commits into
mainfrom
fix/24-reconciliation-status
Aug 22, 2026
Merged

Complete agent reconciliation and fleet revision status#35
artemnikitin merged 20 commits into
mainfrom
fix/24-reconciliation-status

Conversation

@artemnikitin

Copy link
Copy Markdown
Owner

Closes #24.

Summary

Current main already carries the initial revision fields, agent heartbeat snapshot, registry persistence, and node/service visibility from PR #29. This PR completes the remaining issue #24 acceptance gaps:

  • bound registry status bodies, conditions, service summaries, per-service volumes, identifiers, revisions, and messages; reject duplicate/invalid payloads while preserving old-agent heartbeats as unknown
  • fail multi-label config loading closed and report separate config fetch/parse, capacity, image, network, VM, local-route, and peer-route conditions
  • preserve peer-route failures as non-blocking applied degradation and expose status phase/conditions from the same snapshot in local metrics
  • add GET /v1/status, fireworkctl status, and an overview UI section deriving published/progressing/converged/degraded/failed/unknown fleet state
  • return deterministic node sets for converged, degraded, progressing, failed, stale, down, and unknown nodes; truncated or mixed-version status cannot prove convergence
  • treat an empty desired/empty relevant-node revision as converged without allowing non-empty desired state to converge vacuously
  • document the exact status meanings and payload limits

Issue #22 is implemented separately in PR #34 and should be merged first as the runtime-recovery prerequisite described by the design. This branch is independently based on current main; after #34 merges, this PR may need a small conflict-only rebase in the shared agent/reconciler files.

Validation

  • make fmt
  • make test
  • make lint
  • make test-race
  • make smoke-local
  • make build-all
  • node --check internal/controlplane/web/app.js

artemnikitin and others added 20 commits July 19, 2026 19:59
Fleet convergence rejected truncated service data but never checked that
the services it did receive covered the node, and assessConditions treats
an empty condition list as clean. A current PhaseReady heartbeat with
matching revisions, no services, and no conditions was therefore
classified as converged: the endpoint reported a healthy fleet on the
strength of evidence it never received.

This also disagreed with /v1/services, which reports
service_status_missing for the same snapshot. The node check now reuses
findAgentService, so the two views cannot diverge.

An agent that completes a tick always sends every condition, because it
finalizes unevaluated ones as unknown. An incomplete set therefore means
the telemetry does not describe a completed reconciliation, and requiring
the blocking set costs a healthy agent nothing. PeerRoutesReady is not
required: being non-blocking, its absence can only mask a degraded
classification, never a failed one.

The shared test helper now builds the telemetry a healthy agent actually
sends. The degraded and future-condition cases append to that set rather
than replacing it — replacing it would have made them incomplete, so they
would have passed as unknown and quietly lost their coverage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two separate ways the reported conditions disagreed with reality.

NetworkReady was set true immediately after routing metadata preflight,
but host networking is attempted later, inside Reconcile. A tick that
failed at capacity or image sync therefore reported NetworkReady=true for
a revision whose networking was never attempted. It is now left
unevaluated so finishTickStatus finalizes it as unknown, which is what the
per-tick contract says an unreached stage should be. The success path
still sets it after Reconcile returns, and the unchanged-revision fast
path still covers it, so a converged node is unaffected.

Metrics were snapshotted inside failAgentStatus, before finishTickStatus
rewrote unreached conditions to unknown. After an earlier successful tick
Prometheus kept reporting later conditions as true while /status and the
registry reported them unknown, and on a first failing tick it omitted
them. Refreshing the snapshot inside finishTickStatus covers every early
failure path through one choke point.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A SetupPortForward failure during create was logged and discarded, so
Reconcile returned success and the agent advanced the applied revision.
Because an existing VM produces no further plan actions, nothing retried
it: the fleet stayed permanently converged while cross-node routing was
broken.

Neither obvious fix works. Returning the error without retrying reports
one failed tick and then converges again. Rolling the service back retries
but crash-loops a running VM on a durable failure such as the host-port
collision this deployment has already hit.

So port forwards now converge on every tick, like peer routes already do.
Re-asserting is cheap because the helper checks before it adds, a
transient failure self-heals, and a persistent one keeps failing the tick
instead of being reported once and lost. The create path still attempts
setup for immediacy, and its warning now says what retries it.

Teardown failures are recorded for the tick rather than returned, because
the update path skips the recreate when deleteService errors and a
transient teardown failure must not leave a service deleted. They surface
in the tick result instead. Obsolete rules left by a failed teardown are
still not pruned; that needs rule enumeration rather than asserting
desired state, and is not attempted here.

Host networking moves behind an interface so this is testable without
touching real iptables rules. New keeps a nil *network.Manager out of the
interface: stored directly it would be a non-nil interface, and every
guard would pass before dereferencing nil.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Input validation required non-empty, unique service names but no length
limit, while the registry discards an entire agent_status carrying a name
over 128 bytes. An accepted configuration could therefore run while every
heartbeat describing it was dropped, leaving the node permanently unknown
in fleet visibility with nothing pointing at the cause.

Validation runs after tenant expansion, so this checks the composed name
that is actually reported, not the input name. The limit is read from
statusmodel rather than restated, so the two cannot drift.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Correcting 89d8a2a, whose message claimed port forwards "converge on
every tick, like peer routes already do". They did not. The convergence
lived inside Reconcile, and the unchanged-revision fast path returns
before Reconcile. On a single-label node with a store revision — the
common case — steady-state ticks skipped it entirely, so the rules were
re-asserted only when the revision changed. Peer routes really are
refreshed on that path, which is what made the comparison wrong rather
than merely imprecise.

The failure this was written for is exactly the one it missed. Host DNAT
is flushed by things that have nothing to do with the config revision: a
firewalld reload, a container runtime restart. Meanwhile the same path
calls markUnchangedRevisionReady, which reports NetworkReady, and the
control plane now treats that as evidence of convergence. A node could
report converged with its port forwards gone.

The fast path now converges port forwards through an exported
SyncPortForwards and only reports ready when that succeeded. The route
refresh still runs either way, so one failing subsystem does not stop the
other from converging.

The earlier tests could not have caught this: they drive Reconcile
directly. The new ones drive the agent tick, which is where the gap was.
markUnchangedRevisionReady's comment now also distinguishes the stages it
genuinely re-verifies from the ones it infers from the revision being
unchanged, since the control plane weighs them all equally.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The 4 MiB cap sat below the largest agent_status the validator accepts,
measured at 11.81 MiB. The bound is driven by per-volume messages:
MaxServices x MaxServiceVolumes is 6400 volumes, each with a LastError
that BoundedMessage limits to 256 runes rather than bytes, so up to 1 KiB
apiece.

An oversized body is truncated by MaxBytesReader and fails JSON decoding,
so the handler returns 400 before it can reach
validatedHeartbeatAgentStatus. That rejects the node's liveness
heartbeat, not just its telemetry: LastSeenAt stops advancing, the node
goes stale and then down, and the fleet reports unknown. Treating status
as optional telemetry was meant to prevent exactly that, so a valid but
large status failed harder than an invalid one.

The new test measures the largest valid heartbeat and fails if the model
bounds ever grow past the cap, so this cannot drift back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
setAgentStatusSnapshot wrote lastAppliedAt and lastAppliedRevision, which
recordConfigApply owns. recordConfigApply reports the store revision;
the snapshot carries AppliedRevision, which markAgentStatusApplied has
already rewritten to the rendered revision. The later write won, so
firework_agent_config_last_applied_revision_info silently changed which
revision it names, and any dashboard correlating that label with store
revisions stopped matching with no other signal.

The snapshot now writes only the phase and condition gauges it is for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The reconciliation conditions were written out in three places: the
agent's per-tick list, the control plane's blocking set, and the literal
case list inside assessConditions. They agreed, but nothing held them
together.

That matters because the control plane refuses to call a node converged
unless every blocking condition is present. An agent-side rename without
a matching control-plane change would leave no node ever classifiable as
converged — silently, and permanently, since a missing condition looks
the same as incomplete telemetry.

All three now derive from statusmodel, and assessConditions classifies by
lookup rather than a literal list. A test pins the invariant the control
plane depends on: every condition it requires is one an agent reports.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…at size

- Empty desired state no longer converges until every known node confirms
  it via a fresh heartbeat, since the controller deletes legacy node
  configs instead of publishing explicit empty ones and an agent treats
  the missing file as a fetch failure.
- A failed DNAT/network teardown after a VM is removed is now retried
  every tick (and, via WithStateDir, persisted across an agent restart)
  instead of being forgotten once Plan has no further delete action for
  it. Retries distinguish a reclaimed service name from a genuinely
  deleted one so they never tear down a live VM's own tap device, while
  still catching and retrying a stale DNAT rule left behind by a changed
  port forward.
- The 16 MiB heartbeat cap is now actually bounded end-to-end: every
  previously-unbounded AgentStatus/ServiceStatus/VolumeStatus field is
  either length-validated or (for mount_path and the composed
  LogicalID) truncated on the agent's send path and rejected at config
  validation time, with a test that builds the worst case through the
  real agent status path rather than by hand.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… name

pendingNetworkTeardowns had the same overwrite/erase bug as the port-forward
map fixed in 45bae2a: it was keyed by service name on the false premise that
a tap device's path is derived purely from the name. svc.Network.Interface,
when set explicitly, overrides the default tap-<name> path, so a service
moving through two consecutive generations (A -> B -> C) before an earlier
device's teardown ever succeeds could lose track of A's still-pending tap the
same way. Key pendingNetworkTeardowns by (tapName, bridgeName) instead,
mirroring the pendingPortForwards design, with
TestReconcile_ConsecutiveUpdatesTrackEachGenerationsStaleNetworkDeviceIndependently
as the regression proof.

Also: atomicWriteFile now fsyncs the parent directory after the rename, so
the journal's atomicity claim covers the rename's own durability, not just
the write. Documented the new node_id/backend_id byte limits in
docs/configs/README.md.

The persisted journal's network field changed shape (map -> slice) to carry
a struct key; this is safe since the branch has never been deployed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ately

Two blocking review findings at 1fff31a.

Write-ahead journal. deleteService removed the VM first and only then
recorded what still needed cleaning up. A crash in between left the VM
gone, no delete action for Plan to ever emit again (the service is absent
from both desired and actual state), and no durable record of the host
resources it left behind: the node restarted and reported converged with
stale DNAT rules and devices on the host. The intended cleanup is now
journalled before Remove, and a failed journal write aborts the removal
instead of proceeding unrecorded. persistPendingTeardowns returns an error
rather than only logging one, and refuses to write on a corrupt journal --
Reconcile appends the corrupt-journal error but continues, so the
write-ahead gate has to fail closed itself. Entries are cleared as each
resource is actually released; a failure on that path is logged only,
since every operation the journal drives is idempotent.

Independent network device tracking. TAP and bridge were tracked as one
composite (tapName, bridgeName) identity and retried through
NetworkManager.Teardown, which always deletes both. A config that drops
host_dev_name while keeping the same explicit interface makes only the
bridge obsolete, but the composite key differed, so the retry fired and
took the live TAP down with it while reporting success. Each device is now
tracked by its own (kind, name) identity and released through new
component-specific DeleteTAP/DeleteBridge operations, and no device
claimed by any running service is ever deleted. Teardown stays on the
interface for createService's rollback, where deleting both components is
correct because it undoes a Setup from the same call; that is now
commented rather than left to be re-flagged.

The persisted journal's network field changed shape again to carry the
per-device key; safe since the branch has never been deployed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…sion the journal

Addresses four of seven review findings on 94e8e78.

[P1] Port-forward retry could delete a rule claimed by another service.
retryPendingPortForwards guarded only against entry.ServiceName being
live, but a DNAT rule is identified by its tuple alone: after a rename or
replacement, another service can legitimately claim the exact
(hostPort, guestIP, vmPort) a previous one left pending, and the retry
would delete that live rule while reporting success. This was an
incomplete application of the previous commit -- the identical global
claimant guard already existed in retryPendingNetworkDevices.

[P1] Failed-create networking cleanup was untracked. createService
discarded the rollback error after a failed VM start and journalled
nothing beforehand. A service whose Start never succeeded is absent from
vmManager.List(), so once it is dropped from desired state Plan emits no
delete action and its devices are orphaned; a crash between Setup and
Start left them with no record at all. Devices are now journalled before
Setup, the rollback goes through the component-level teardown so failures
are recorded and retried, and the entries are cleared once the running VM
claims them.

[P1, partial] TeardownPortForward reported success when it could not
resolve the host ingress context: it fell through to legacy-only cleanup,
and removeIPTablesRule treats "no such rule" as success, so the scoped
DNAT rule survived while teardown returned nil. Resolution failure is now
a teardown failure, which keeps the rule on the retry list. Persisting the
resolved rule identity at setup time is the remaining half and is not in
this change.

[P2] The journal is now versioned, decoded with DisallowUnknownFields, and
validated on load. The previous release's shape is valid JSON that decodes
into zero-valued entries -- a device with an empty kind and name deletes
nothing and then clears itself -- which silently discarded exactly the
cleanup the journal exists to guarantee. Any other shape now fails closed.

The composite Teardown is removed from the reconciler's NetworkManager
interface entirely; every removal now goes through DeleteTAP/DeleteBridge
one resource at a time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ed VM converged

Addresses all four remaining review findings on 88ee1ac.

[P1] Partial node retirement reported false convergence.
scheduler.BuildNodeConfigs omits a node left with no services, and
publishRendered then deletes its nodes/<node>.yaml. An agent treats a
missing config as a fetch failure, not as "run nothing", so it keeps every
VM it had -- while the fleet view derives its relevant node set from the
same placement and stops counting that node at all. During an A -> B
migration, B alone converged the revision while A still ran the old
workload. The controller now appends an explicit empty config for every
active node the placement gave no work to, before the revision metadata is
stamped, so retirement becomes an ordinary convergence step: the node
fetches it, reconciles to zero services, reports the new revision, and
stays in the relevant set until it does. Nodes absent from activeNodes
entirely (down, stale, decommissioned) are deliberately untouched.

[P1] The unchanged-revision fast path claimed a crashed VM as converged.
markUnchangedRevisionReady asserted VMsReconciled and Reconciled true
unconditionally. Those describe work whose inputs have not changed, which
is not the same as its outputs still holding: a VM can fail after the
revision was applied, and this path skips reconciliation entirely. It now
checks for failed VMs first and reports vm_reconcile_failed -- the same
vocabulary the reconciling path uses -- and returns false so the caller
does not publish PhaseReady over the top of a failed condition.

[P2] The journal decoder accepted trailing content. json.Decoder.Decode
reads one value and stops, so {"version":1}{"network":[...]} was accepted
as an empty journal. A second decode must now return io.EOF.

[P2] A corrupt journal blocked non-networked creates. journalPendingCleanup
persisted even when the mutation added nothing, so a service with no
network config -- and therefore no host resources to clean up -- could not
start. It now skips the write when neither map changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@artemnikitin
artemnikitin merged commit b32e42b into main Aug 22, 2026
8 checks passed
@artemnikitin
artemnikitin deleted the fix/24-reconciliation-status branch August 22, 2026 10:22
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.

Report applied revisions and reconciliation conditions from agents

1 participant