Skip to content

Node adoption from the UI + missing-node alert + project_status loaded/uuid (869ebkkme, 869dr2kzh) - #32

Merged
ibiltari merged 9 commits into
rc_1from
feat/project-status-loaded
Oct 9, 2026
Merged

ibiltari merged 9 commits into
rc_1from
feat/project-status-loaded

Conversation

@ibiltari

@ibiltari ibiltari commented Oct 9, 2026

Copy link
Copy Markdown
Member

Lands the node-adoption engine side, written 2026-09-04 and run on the taller rigs ever since:

  • nodelist_modify hop (editor → engine → nodeconf): the adopt/un-adopt request the editor was already sending; refused instantly when nodeconf is not running, and refused while a project is running or loaded.
  • Missing-node alert: /engine/status/cluster_warning tells the operator when a project's nodes are missing or unreachable — it warns and never blocks GO (required untouched, pinned by a parametrised test).
  • Runtime cluster liveness exposed to the UI.
  • project_status tells a stopped project from no project, and returns the uuid (869dr2kzh); a failed load clears load and the diagnosis instead of leaving the previous project's name on the wire.

Merge of rc_1 into the branch: only CLAUDE.md conflicted (SPDX header form). load_project clears load at entry, and the 869fedahu cue-name burst still follows the final load; clients clear their name table on every change of load, so both orders hold. Plus a black pass on the branch's own files (CI lint).

Same tree as the 0.1.0rc6-3+nodeadopt2 test build that test and test2 have run since 2026-10-09 (load arms all nodes, GO plays). Full suite on test2: 1292 passed (idle rig, scratch copy). black / isort / flake8 clean.

Frontend side already on dev; editor PR and nodeconf PR land alongside.

…lling

The UI, the editor relay and cuems-nodeconf all implement adopt/un-adopt.
The engine hop never did: handle_editor_command's dispatch table has no
'nodelist_modify' key, so every click from the settings panel reached
`raise ValueError("Command nodelist_modify not recognized")`. The wiring was
written once (4e5680d) but survives only under refs/tags/archive/
feat-node_adoption, an ancestor of no branch.

Re-landed, with the three things that version got wrong:

* modify_action now reaches nodeconf. Every command_dict entry is invoked as
  (value, context) — a two-argument contract with nowhere to put it — so a
  table-registered handler would send modify_action=None and nodeconf would
  answer "Invalid modify_action: None" to every single click. The message is
  bound before the table and dispatched directly.
* A None reply is handled. Communicator.send_request swallows every exception
  and returns None, which is what an absent /tmp/nodeconf.ipc looks like — and
  nodeconf ships disabled on most of the fleet. The old code called .get() on
  it and raised AttributeError.
* Failures reply exactly once, and never by raising. editor_command_callback's
  except clause also replies, so a raise reaches the operator as "Engine
  reports error: Command <class 'RuntimeError'>: ..." and a handler that
  replies *and* raises puts two replies on one NNG Rep context.

Adoption is now refused while a project is running AND while one is merely
loaded: _resolve_cluster_state computes _required_nodes once, at load_project,
so a REMOVE accepted mid-load would leave the GO gate waiting on a node the
operator just removed. Refusing is the option that never stops anything.

On success the engine re-reads network_map.xml and re-registers the per-node
OSC routes, because its own copy was loaded once at startup — without this the
adopted node stays invisible to the GO gate until a restart, i.e. the button
would report success and change nothing observable. If that reload fails the
operator is told it is a partial success, not given a green toast over a stale
topology.

15 tests, and the whole test_controller_commands/gating/nodehub set still green.

Refs: 869ebkkme
Plan: cuems-RELATIONS/Plans/2026-09-04-nodelist-modify-adoption-path.md
nodelist_modify is deliberately NOT in command_dict — every entry there is
called as (value, context), so a table-registered handler could never receive
modify_action and nodeconf would reject every click. Records that, plus: a
missing nodeconf socket returns None rather than raising (and nodeconf is
disabled on most of the fleet, so that is the common path); failures must reply
exactly once and never by raising, or the operator reads "Engine reports error:
Command <class 'RuntimeError'>: ..."; and adoption is refused while a project is
loaded because _required_nodes is computed once, at load.

Also documents the targeted network_map re-read after a successful change —
including that get_nodes_by_adoption mutates in place and survives one pass, so
the OSC re-registration is only valid on the freshly reloaded dict — and
qualifies the "restart both daemons" note, which now has exactly one exception.

Refs: 869ebkkme
…lear it

Adversarial review of Part A found that nothing asserted the one line the plan
called out by name — handle_editor_command's set_editor_request("") after a
successful confirm, which the archived version omitted. Deleting it would have
failed no test. Now two tests cover it, on the success and the refusal path.

_nodelist_refuse also left _editor_request_uuid set, unlike the generic error
path which captures it, clears it, and replies with it explicitly. Harmless
today because editor_command_callback overwrites it on every call, but it was
an unexplained deviation from the one-reply-and-clean-state discipline the rest
of this handler follows.

73 passed across nodelist_modify + controller_commands + controller_gating.

Refs: 869ebkkme
Found on the rig, and it contradicts what the plan assumed. With
/tmp/nodeconf.ipc absent, request_to_nodeconf does NOT return None quickly —
it burns the engine's full 15 s IPC timeout and then raises TimeoutError.
Because editor commands are serialized by the engine's single listener, a
second click queues behind the first, so the operator's second attempt can hit
the editor's own 25 s timeout and read "Engine did not respond" instead of a
reason. And nodeconf ships disabled on most of the fleet, so this is the common
path, not the rare one.

Now the socket is checked first: the refusal is instant (measured 0.00 s on the
rig, against ~15 s before) and says what to do —
"systemctl enable --now cuems-nodeconf". The IPC call also carries an explicit
5 s timeout; adopt/un-adopt is a local XML rewrite and has no business taking
the engine-wide 15 s. The None-return handling stays: send_request still
swallows other failures into it.

Rig, on the FormitGo N97 controller: adopt answered in 0.03 s with nodeconf up,
refusal in 0.00 s with it down. 48 tests green here (nodelist_modify +
controller_commands), 19 in this file.

Refs: 869ebkkme
The engine already knows who is answering — _probe_cluster_liveness is what the
GO gate trusts — but that view never left the process, so the UI had no way to
tell "known to nodeconf but absent" from "dead right now". Adds a read-only
cluster_status editor command returning alive / adopted / controller / age_s.

age_s is computed here rather than handing out a monotonic timestamp the
browser cannot interpret. Repeat calls inside 2 s are served from cache: each
probe pings every adopted node and the settings panel polls while it is open.
The answer is always a populated dict — the dispatch path confirms only on a
truthy result, so an empty one would leave the editor waiting out its 25 s
timeout.

Also writes down, at the probe itself, the invariant this makes load-bearing:
the pong bookkeeping is shared instance state with no correlation id, so two
concurrent probes would eat each other's replies. Nothing enforces that — it
holds only because the engine's single editor listener awaits one handler
before accepting the next. Until now the only caller was project load; the UI
can reach it now, so a future "process editor commands concurrently" change
must add a lock or a correlation id first.

Rig (FormitGo N97): answered in 0.01 s; on a single-box controller
alive == adopted == [controller] via the no-remote-nodes early return, with no
ping broadcast; three rapid calls produced age_s 0.0/0.001/0.002 and exactly
one real probe in the journal.

25 tests in this file.

Refs: 869ebkkme
Un-adopt a node — or let it die — then load a project whose cues use its
outputs: the load answers OK, the GO gate turns green, and those cues
never fire. _resolve_cluster_state already knew, and only told the
journal.

Two defects, one of them a lie. `required` is (adopted & alive & project)
| {controller}, so a project node that did not pong is EXCLUDED from the
gate — yet the log line for exactly that case said "GO blocked". Anyone
debugging a mute show from the journal was sent the wrong way.

The operator decision is warn loudly, never block GO, so `required` is
untouched here and a test pins it against the original formula in every
shape. What changes is that the diagnosis leaves the process:

* missing     — project nodes not in the cluster at all
* unreachable — adopted and used by the project, but silent

Three surfaces, because none alone reaches the operator: pushed at load
as /engine/status/cluster_warning (JSON, since OSC carries no lists),
replayed in _on_ws_client_connect (with boot auto-load the browser is
opened after the load), and readable in get_cluster_status → the
editor's node_status, read fresh outside the 2 s clamp so a poll landing
just after a load cannot serve the pre-transition answer.

Always sent, empty lists included: a clean load has to actively erase the
previous warning. Cleared on unload and at the top of load_project, so an
early failure does not leave the last project's warning on screen — but
NOT from _clear_playback_state, which also runs on STOP, where the
project is still loaded and its missing nodes still missing.

load_id increments per load so a UI can tell an operator's retry
(identical lists, new id) from a reconnect replay (same id).

The controller is excluded from both lists: it is in `required`
unconditionally and is not always flagged adopted in network_map.xml, so
including it raised a false alarm on a healthy load — caught by the test
written for it. A false alarm teaches operators to ignore the real one.

Log lines now carry role_id/alias where the map has them; wire payloads
stay UUID-only, since names are mutable projections the browser already
resolves.

Tests: +23 (test_cluster_warning.py). test_project_go.py cannot run on
casas — it wedges in snd_seq_open, a pre-existing ALSA collision on that
box, unrelated to the GO path this commit does not touch.

Plan: cuems-RELATIONS/Plans/2026-09-04-missing-node-alert.md
Loaded-but-stopped collapsed into status "none", so a client could not
tell a project that is loaded and waiting from no project at all — while
/engine/status/load went on naming it all along. That contradiction is
the one power-bridge's field notes recorded as "engine says
project_status=none but keeps broadcasting a stale loaded".

Three states now: running, loaded, none. project_uuid is populated in
the first two, and the invariant is one-way — a status other than "none"
always carries a uuid, so running-with-no-script degrades to none rather
than offering a running the client cannot act on. project_unix_name
rides along so a consumer needing the uuid never forces the broadcast to
carry one; that is how 869dr2k2h is closed, additively. The TODO asking
for the opposite is removed and replaced by the reasoning: the frontend
prints that string in the transport bar and matches it against
project.unix_name, so a uuid there shows a raw uuid and breaks the match.

The load state is read from two attributes a concurrent load can be
mutating, so they are ANDed: a torn read falls to "none", never to a
"loaded" naming a project the engine does not hold.

Second defect, same family: load_project dropped self.script before
validating the new project but never dropped `load`, so a failed load
left the engine holding nothing while still broadcasting the previous
project's name. The transport bar showed it and power-bridge believed
it, accepting /setnextcue and /gocue against a project that was gone.
The clear goes next to _clear_load_diagnosis(), which is there for the
same reason, so the two signals die together. Deliberate cost: during a
successful load the bar reads "—" until the new name arrives, instead of
the stale old one.

Tests: the one asserting the old behaviour is rewritten, plus twelve
covering the four windows where a single signal lies, the uuid
invariant, Uuid→str coercion, a script without unix_name, and the
running guard leaving `load` alone.

869dr2kzh, 869dr2k2h
Only conflict: the SPDX header form of CLAUDE.md (kept rc_1's). load_project
now clears 'load' at entry (project-status) and bursts cue names after the
final 'load' (869fedahu) - the clients clear their name table on every change
of 'load', so both orders hold. Same tree as the integ/rc6-3-node-adoption
build (0.1.0rc6-3+nodeadopt2) that test and test2 run since 2026-10-09, minus
its version suffix.
@ibiltari
ibiltari requested a review from backenv as a code owner October 9, 2026 10:10
@ibiltari
ibiltari merged commit 2d923da into rc_1 Oct 9, 2026
2 checks passed
@ibiltari
ibiltari deleted the feat/project-status-loaded branch October 9, 2026 10:16
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.

1 participant