Handlers are play-global and deduplicated by name, the last registration
wins, and a notification resolves in the handler's own context rather than
the notifier's. Every app normally re-registers the compose handlers in its
own context through the backend stack right before it notifies, which is what
keeps that resolution correct.
Reconciling entered each client through tasks_from, so the client's core never
ran and no re-registration happened. The last registration in the play then
came from a role imported without vars, which lets the handler-role literal
through, and the guard caught a flush bound to a role that owns no instance
directory. Had the last registration belonged to a real app the guard would
have passed and the restart would have gone to the wrong one, unnoticed.
Rebinding through an include gives the client id as an include parameter,
which outranks the role vars the handler import sets. Flushing inside the loop
keeps a client that does not flush on its own from having its notification
collapsed into the following client's context at the trailing flush.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The task name referenced the loop variable but is rendered outside the
loop, so every run logged the task as "Reconcile << error 1 -
'mcp_client' is undefined >>". The loop itself was fine; the damage was
diagnostic, which is the worst kind to leave in a log someone reads
while hunting an unrelated failure. The per-iteration label already
carries the client id.
The reconciler loaded a client role without telling it which application
it is, so the compose handler resolved a foreign instance directory at
flush time and the guard rejected it. The include now binds
application_id and domain the way the app loader does.
It also reconciled clients the inventory declares but the deployment
never carried, whose instance directory does not exist at all. Only
clients present on the stack host are reconciled now, which still covers
a client deployed by an earlier run.
The reconciler now runs in the server stage between the app pass and the
e2e pass, so every client sees the full provider set instead of only the
providers that happened to deploy before it. Handlers are flushed while
the clients are still bound, and the strict flag is released once the
reconciliation is over.
A structure lint pins both edges of that window so the include cannot
drift back into the app pass or past the e2e pass.
Every MCP role keeps its task tree under tasks/utils/ without numeric
prefixes, so a caller can reach any of them through include_role
tasks_from. utils/roles/mapping.py holds that path as the single source.
Ansible resolves a relative include against the including file's own
directory when that file was reached by path, but against tasks/ when it
was reached through tasks_from. The same file therefore loaded from its
own role and failed from a caller using tasks_from. Every nested include
now names {{ role_path }}/tasks/utils/..., which holds under both bases,
and a lint walks each tasks_from entrypoint transitively to keep it so.
The exec-gating lint learns the same form, otherwise it loses the include
chain and reports inherited IS_STACK_HOST gates as missing.
The expression resolving a provider's stored token appeared eighteen
times. plugins/lookup/mcp_credential.py replaces it, and mcp_probe_owner
disappears from eight roles: it was only ever a step towards the bearer
and no probe read it.
The Hermes and OpenClaw probes stop aborting the play while it is still
running. Roles deploy in dependency order, so a client probing during its
own role can run before a provider exists at all, and failing there
prevents that provider from ever deploying. The verdict moves to the
reconciliation pass, gated on MCP_RECONCILE_STRICT, which the reconciler
sets and group_vars declares so the readers need no default.
The sys-svc-mcp-reconcile role is added here; wiring it into a stage is a
separate change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>