An aborted Playwright run - watchdog SIGTERM, task timeout - returns a result dict with neither stdout nor rc. The reporting task dereferenced both, so it became the fatal error itself: the log ended on "object of type 'dict' has no attribute 'stdout'" while the real cause, a disk-exhaustion abort with rc 75, sat eight thousand lines earlier. Guard all three reads, fall back to msg for stderr because that is what Ansible fills on an abort, and treat an absent rc as a failure so the run is recorded and named by the aggregate check rather than vanishing behind a template error.
The distro sweep's summary also mislabelled the distros it never started: they kept the initial "not reached within the budget" note even when the sweep aborted on a failure with 12214 seconds of budget left, pointing at a budget problem that did not exist. Say what happened instead.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
act merges its --env-file, which defaults to .env, after the workflow-level env block (pkg/runner/run_context.go:84 at v0.2.89) and interpolates only afterwards, so INFINITO_DISTROS: ${{ inputs.distros }} was replaced by the repository's own five-distro list before the expression was ever evaluated. Every workspace leg therefore swept all five distros instead of its own: 25 distro deploys where 5 were intended, which is what walked the five legs into their 345-minute cap. The drill step's name kept rendering the single distro because it is interpolated from the workflow model rather than from the env map, which is why the logs read as a contradiction.
Pass INFINITO_DISTROS as an --env pair instead, the layer act itself wins with, where --env beats the env-file because the file only fills keys that are absent (cmd/root.go:364-366). The expression was duplicated across ACT_ENV and ACT_INPUTS, so name it once.
Wrapping the assignment for readability required the splitter to tolerate whitespace: inside double quotes the shell removes the backslash-newline but keeps the continuation line's indentation, so every pair after the first arrived with a leading tab.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The new handler had neither retries nor an assertion. update_config carries no failure_action, so Docker's default is pause: one flapping healthcheck inside the monitor window pauses the rolling update, docker service update exits non-zero, pipefail propagates it and any_errors_fatal kills the whole deploy - where the neighbouring converge handler would have retried. Give it five attempts fifteen seconds apart, matching how the registry-sync handler beside it already treats a transient action.
The registration probe only notified; nothing checked that the recreation actually registered the plugin. A plugin that can never register - corrupt pnpm tree, engine mismatch, invalid library code - therefore reported changed on every run, recreated the stack every time and still left the play green. Flush the recreation, re-resolve the container id the recreation invalidated, and assert the registration with a bounded wait, so that case fails loudly in the run where it happens instead of churning forever in silence.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The pipeline gate only listed twelve of seventeen jobs in its needs, leaving out wait-fork-prereq-run, fork-prereqs-ready, build-ci-images, images-mirror-missing and test-dns. A failure in any of them turns its dependents into skipped rather than failure, and the gate's check matches only failure and cancelled, so the run would have concluded green without images ever being built.
A caller-supplied whitelist was also passed through verbatim. Run 30492550883 was dispatched with web-app-prometheu; discovery dropped the unknown id without a warning, the empty resolution became [], the deploy workflows skipped their whole matrix on that condition and the parent job still concluded success. Validate every id against roles/<id> and exit naming the offenders, so a typo cannot quietly remove an application from a run.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
docker stack deploy leaves tasks running when the service spec is unchanged, so a change made inside a container never reaches the process. PeerTube's CLI installs plugins with register:false, so the OIDC plugin sat installed and enabled in the database while the running server had loaded nothing: /login rendered only the local form and both OIDC specs timed out through all three attempts. Compose never showed it because its handler force-recreates.
Add a container recreate notify. In swarm it forces a task recreation per stack service and waits for convergence; in compose it maps onto the existing --force-recreate, so a role states the intent once and both modes honour it.
In the peertube role the install probe now tests node_modules/<npm-name>, where pnpm puts the plugin code; the old path was the data directory that registerPlugin creates at boot, so it never existed and the plugin was reinstalled on every run. The settings upsert derives changed from the rowcount via RETURNING, because database_query reports changed from the kind of statement rather than from what it touched. A registration probe notifies when the running process has not loaded the plugin, so an already-broken instance heals itself instead of walking past both earlier probes. The plugin:enable task is gone: npm has no such script, the call always failed and failed_when swallowed it.
The spec now polls the button lookup instead of counting once twenty milliseconds after goto, while still asserting on the URL so a failure keeps printing the address the browser actually sat on.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The default base was /tmp, which is tmpfs on this host: a reboot took a whole worktree and its uncommitted work with it. Derive the base from the origin remote instead, as ~/.local/share/worktrees/<domain>/<account>/<repo>, and let the branch keep its slashes so it nests, which also makes the infinito-wt- prefix redundant. Without an origin remote it now fails loudly rather than inventing a location.
up.sh bumped only INFINITO_SWARM_LAB_SUBNET per slot while the five swarm lab node IPs stayed at default.env's 192.168.244.x, so every worktree from slot 1 on got a lab network in a different /24 than the nodes it places in it, and collided with the primary checkout's lab. Pin all six values from one octet.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The sweep's own governor was sized against the 6h platform kill (19800s) while the deploy step was capped at 300 minutes, so it could never fire: GitHub hard-killed the step mid-distro and reported a run that simply did not fit all distros as a job failure.
Size the ladder instead, 10 and 5 minutes apart: sweep budget 340min < deploy step 350 < job 355 < platform kill 360. The swarm per-leg ceiling moves to 350 so it stops matching the job cap, where it was dead code. The rescue-state dump drops from 10 to 5 minutes so it cannot alone consume the window between the job kill and the platform kill; measured teardown is 10-38 seconds.
A new lint asserts the budget stays under the governing step cap, the lower half of the invariant whose upper half the headroom lint already carried; that lint's ceiling moves from 345 to 355.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
MCP provisioning was idempotent against our own token store only. When a reinstall leaves the hub volume in place but resets the secrets store, the two disagree: the store is empty while Home Assistant still knows the client_name. It then refuses the second long-lived token with {"code": "unknown_error"}, which says nothing about the cause, and the task burned all 30 retries on it.
The mint step now lists the refresh tokens, deletes every entry carrying our client_name, and only then creates the new one. A token whose store entry is gone cannot be used by anything anyway; it just holds the name.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Without web-svc-asset the platform logo URL falls back to clearnet, which an
onion deploy's Tor-proxied browser cannot fetch — naturalWidth stayed 0 while
the URL assertion above it passed. The spec gated on cdn only, so it ran in a
scenario where it cannot hold. The added poll also separates 'never loaded'
from 'measured too early', since toBeVisible says nothing about the bytes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The unconditional build-script copy relied on the gated manifest loop above
it creating the parent as a side effect. When every discovered role is absent
from the inventory that loop skips entirely, and the copy dies with
'Destination directory /opt/compose/cdn/volumes does not exist'. The canonical
creator runs later, via sys-stk-full, and cannot be hoisted because the
compose one-shot build consumes the staged files at stack-up.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
environment-bootstrap runs inside the CI workspace containers, where a kernel
module can never be loaded: three distros lack modprobe entirely and two find
no /lib/modules for the host kernel. The DR drill preflight already names the
target, which is how it is meant to be invoked.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The spec navigates through gotoOnion and carries no timeout literals of its
own, so the import had no consumer and failed eslint's no-unused-vars, which
took the whole CI lint job down.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
TOR_CONTAINER_DNS_HOST is empty in swarm mode by design, so no dnsmasq
listener appears on the docker bridge there. The lookup gated on the tor
group alone and wrote 172.17.0.1 into daemon.json anyway, handing every
nested container a resolver nothing answers on — nested image builds then
died resolving dl-cdn.alpinelinux.org.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The endpoint port resolver preferred ports.local and only reached for ports.internal when the block declared exposure: internal. Its single consumer builds a container-network URL (http://<service_key>:<port><path>), where a host-published port is always the wrong one. A server role that omits exposure would therefore have silently received the host port, which is the same class of silent-wrong-value the exposure handling was added to remove.
The order is now internal first, unconditionally, and the exposure argument is gone. All three MCP roles declare exposure: internal, so nothing rendered changes today. exposure stays part of the MCP schema, where it documents who may reach the endpoint.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The merge of the swarm-nfs work moved utils/unmount_nfs_mounts.sh to utils/unmount/nfs_mounts.sh and updated teardown.sh and the bootstrap, but clean/all.sh kept the old path. make swarm-clean died at the unmount step with "No such file or directory" before it reached container removal, which is exactly the state that needs cleaning up. The host-state release is not added here because all.sh already unmounts DIR_VAR_LIB itself.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
act fills the GITHUB_REPOSITORY* vars with its own placeholders, so the
existing owner guard passes and the resolver hands back ghcr.io/nektos/act/
<distro>. That path does not exist and the swarm node build died on a 403
three minutes into every local run. Returning nothing is what the callers
already treat as 'build it locally'.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Gitea moved the authentication docs from usage/ to administration/; the old path returns 404 and failed the URL reachability test. Probed the candidates: usage/authentication and usage/authentication/ both 404, administration/authentication 308s to administration/authentication/ which answers 200 -- use the 200 form so the result does not depend on whether the checker follows redirects.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
f660dd6bd wrote the placeholder into a parameter expansion, `${INFINITO_IMAGE:-<none, bootstrap will build it>}`, which tests/lint/filesystem/shell/test_no_infinito_defaults.py rejects: an INFINITO_* default in a shell file is a second source next to default.env that drifts silently, and only the empty form is allowed. It was the sole failure of test-lint.
Split the message into the resolved and the unresolved case instead of suppressing the lint; the output is also no longer self-contradictory, since 'published <none>' could not both be true.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
act fills the GITHUB_REPOSITORY* vars with its own placeholders, so the
existing owner guard passes and the resolver hands back ghcr.io/nektos/act/
<distro>. That path does not exist and the swarm node build died on a 403
three minutes into every local run. Returning nothing is what the callers
already treat as 'build it locally'.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three rows disagreed with their workflow files: ci-orchestrator still advertised a distros default that 0f7334b3d removed, test-deploy-swarm omitted distros entirely although it is its only required input, and test-instructions listed one input with a svc-db-postgres default where the workflow declares four on both triggers and picks a random role when role is empty. test-dns is left alone -- its 'required on call; default debian on manual' already describes the real split.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
f660dd6bd introduced `${INFINITO_IMAGE_TAG:?}` in the image resolution, a hard requirement the replaced call deliberately did not have -- ci.sh exits 0 before its own assert when no registry owner is in scope. The check sat after the EXIT trap was armed and before the env layer is sourced, so a missing tag ran collect_and_teardown against a cluster that was never created, and the variable appeared in neither the # Param header nor the strict guard block that carries the other four required inputs.
Move it into that guard block, which runs before mktemp and before the trap, and drop the now-redundant inline assert.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
841a5e0e1 claimed to keep entries whose checkout cannot be inspected, but the check resolved dirname(dirname(gitdir)) -- the checkout's PARENT, e.g. /tmp -- which always exists. The branch was dead code and every entry fell through to `rm -f gitdir HEAD`, including a live worktree whose metadata git can no longer read: that path drops it from the liveness list and it was then unregistered, which is the reported symptom.
Make the checkout directory itself the criterion, and refuse to conclude anything when its parent is not traversable -- `test -d` succeeds on a directory without +x, so a first attempt using -d alone reproduced the same blind spot. Reproduced in throwaway repos: a live worktree with a 0-byte commondir keeps its registration, a deleted checkout is pruned, and a metadata corpse without gitdir is cleaned.
Remaining limit: `git worktree prune`, which the script runs first, shares the blind spot and wins where it can unlink; this guard therefore only decides in the sandbox-pinned case that produced the bug.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both steps resolved their value as `echo "X=$(python3 ...)"`. A command substitution's exit status is discarded when it is only an argument, so echo's 0 won: the step stayed green and wrote an empty value. Measured against the real module -- old shape exit 0 with `INFINITO_DISTRO=`, assignment form exit 1 and nothing written. The empty matrix then surfaced as an unexplained fromJson error one job later, and the empty distro as `INFINITO_IMAGE=infinito-` deep inside the docker run, where set -u cannot help because GITHUB_ENV defined the variable as empty rather than unset.
The prepare step also had no `set` line of its own; it inherited -eo pipefail from the shell wrapper and never had -u.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The distros input carried the full five-distro list as a default on ci-orchestrator and on both triggers of test-deploy-swarm, so a caller that forgot it silently inherited everything -- which is how a single workspace leg ended up drilling all five distros sequentially. test-deploy-compose already required the value; make swarm and the orchestrator match it. Every caller passes distros explicitly today, and entry-manual keeps the only default a human ever sees.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every workspace leg pinned INFINITO_DISTRO=debian and drilled all five distros sequentially, pulling its swarm nodes from ghcr <distro>:latest -- an image from 2026-03-21 whose nfs-ganesha 7.2 segfaults on any accepted TCP connection on 2049. The controller mount then failed with ECONNREFUSED for 26 minutes and took run 30446813110 down after 5h13m, with 3 of 5 distros never reached.
Derive the distro from the leg's own matrix entry, build that image, and drill only it: swarm-zombie takes an optional distros= that defaults to INFINITO_DISTRO, and 00_one.sh prefers the locally built image over the published one. 12_swarm.sh refuses to start when that image is missing, so a broken build fails loudly instead of being masked by a stale registry pull.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The workspace workflow asked the distro SPOT two questions through embedded python one-liners: the matrix image list, and the distro behind a matrix entry. Give both a name -- distro_of_dev_runtime_image() as the counterpart to dev_runtime_image(), and a cli.meta.ci.dev_runtime module shaped like its image_ref neighbour. The empty-SPOT guard that protected the matrix from silently skipping every job moves along with it. No caller yet; the workflow switches over in the next commit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Staleness was decided by reading gitdir inside the metadata dir. Under the agent sandbox that dir is pinned as read-only bind mounts, so a live worktree can read as having no gitdir at all -- prune then removed gitdir and HEAD and left the checkout as 'fatal: not a git repository' with no way back. Decide liveness from git's own worktree list plus each checkout's .git pointer instead, keep entries whose checkout base is not visible rather than guessing, drop the expected EBUSY noise, and report the pinned leftovers once with the command that clears them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Gitea moved the authentication docs from usage/ to administration/; the old path returns 404 and failed the URL reachability test. Probed the candidates: usage/authentication and usage/authentication/ both 404, administration/authentication 308s to administration/authentication/ which answers 200 -- use the 200 form so the result does not depend on whether the checker follows redirects.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
f660dd6bd wrote the placeholder into a parameter expansion, `${INFINITO_IMAGE:-<none, bootstrap will build it>}`, which tests/lint/filesystem/shell/test_no_infinito_defaults.py rejects: an INFINITO_* default in a shell file is a second source next to default.env that drifts silently, and only the empty form is allowed. It was the sole failure of test-lint.
Split the message into the resolved and the unresolved case instead of suppressing the lint; the output is also no longer self-contradictory, since 'published <none>' could not both be true.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
act fills the GITHUB_REPOSITORY* vars with its own placeholders, so the
existing owner guard passes and the resolver hands back ghcr.io/nektos/act/
<distro>. That path does not exist and the swarm node build died on a 403
three minutes into every local run. Returning nothing is what the callers
already treat as 'build it locally'.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three rows disagreed with their workflow files: ci-orchestrator still advertised a distros default that 0f7334b3d removed, test-deploy-swarm omitted distros entirely although it is its only required input, and test-instructions listed one input with a svc-db-postgres default where the workflow declares four on both triggers and picks a random role when role is empty. test-dns is left alone -- its 'required on call; default debian on manual' already describes the real split.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
f660dd6bd introduced `${INFINITO_IMAGE_TAG:?}` in the image resolution, a hard requirement the replaced call deliberately did not have -- ci.sh exits 0 before its own assert when no registry owner is in scope. The check sat after the EXIT trap was armed and before the env layer is sourced, so a missing tag ran collect_and_teardown against a cluster that was never created, and the variable appeared in neither the # Param header nor the strict guard block that carries the other four required inputs.
Move it into that guard block, which runs before mktemp and before the trap, and drop the now-redundant inline assert.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
841a5e0e1 claimed to keep entries whose checkout cannot be inspected, but the check resolved dirname(dirname(gitdir)) -- the checkout's PARENT, e.g. /tmp -- which always exists. The branch was dead code and every entry fell through to `rm -f gitdir HEAD`, including a live worktree whose metadata git can no longer read: that path drops it from the liveness list and it was then unregistered, which is the reported symptom.
Make the checkout directory itself the criterion, and refuse to conclude anything when its parent is not traversable -- `test -d` succeeds on a directory without +x, so a first attempt using -d alone reproduced the same blind spot. Reproduced in throwaway repos: a live worktree with a 0-byte commondir keeps its registration, a deleted checkout is pruned, and a metadata corpse without gitdir is cleaned.
Remaining limit: `git worktree prune`, which the script runs first, shares the blind spot and wins where it can unlink; this guard therefore only decides in the sandbox-pinned case that produced the bug.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both steps resolved their value as `echo "X=$(python3 ...)"`. A command substitution's exit status is discarded when it is only an argument, so echo's 0 won: the step stayed green and wrote an empty value. Measured against the real module -- old shape exit 0 with `INFINITO_DISTRO=`, assignment form exit 1 and nothing written. The empty matrix then surfaced as an unexplained fromJson error one job later, and the empty distro as `INFINITO_IMAGE=infinito-` deep inside the docker run, where set -u cannot help because GITHUB_ENV defined the variable as empty rather than unset.
The prepare step also had no `set` line of its own; it inherited -eo pipefail from the shell wrapper and never had -u.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The distros input carried the full five-distro list as a default on ci-orchestrator and on both triggers of test-deploy-swarm, so a caller that forgot it silently inherited everything -- which is how a single workspace leg ended up drilling all five distros sequentially. test-deploy-compose already required the value; make swarm and the orchestrator match it. Every caller passes distros explicitly today, and entry-manual keeps the only default a human ever sees.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every workspace leg pinned INFINITO_DISTRO=debian and drilled all five distros sequentially, pulling its swarm nodes from ghcr <distro>:latest -- an image from 2026-03-21 whose nfs-ganesha 7.2 segfaults on any accepted TCP connection on 2049. The controller mount then failed with ECONNREFUSED for 26 minutes and took run 30446813110 down after 5h13m, with 3 of 5 distros never reached.
Derive the distro from the leg's own matrix entry, build that image, and drill only it: swarm-zombie takes an optional distros= that defaults to INFINITO_DISTRO, and 00_one.sh prefers the locally built image over the published one. 12_swarm.sh refuses to start when that image is missing, so a broken build fails loudly instead of being masked by a stale registry pull.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The workspace workflow asked the distro SPOT two questions through embedded python one-liners: the matrix image list, and the distro behind a matrix entry. Give both a name -- distro_of_dev_runtime_image() as the counterpart to dev_runtime_image(), and a cli.meta.ci.dev_runtime module shaped like its image_ref neighbour. The empty-SPOT guard that protected the matrix from silently skipping every job moves along with it. No caller yet; the workflow switches over in the next commit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Staleness was decided by reading gitdir inside the metadata dir. Under the agent sandbox that dir is pinned as read-only bind mounts, so a live worktree can read as having no gitdir at all -- prune then removed gitdir and HEAD and left the checkout as 'fatal: not a git repository' with no way back. Decide liveness from git's own worktree list plus each checkout's .git pointer instead, keep entries whose checkout base is not visible rather than guessing, drop the expected EBUSY noise, and report the pinned leftovers once with the command that clears them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The swarm branch predates the shell-or-true lint, so its EXIT-trap collectors
arrived unmarked. They are the legitimate case: best-effort diagnostics and
teardown that must not mask the original exit code, which the trailing
return "${rc}" preserves.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The mapper name is host-global and nothing closes it once a drill succeeds:
the only teardowns sit inside the recover step and in the post-luksOpen
error path. A preserved cluster therefore hands the next round a live
'usbdrill' and cryptsetup refuses with 'Device usbdrill already exists'.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The requirement named Kata with the Firecracker hypervisor. That cannot be delivered on Docker Engine: Firecracker needs the container rootfs as a block device through the containerd devmapper snapshotter, while dockerd always hands the runtime a directory. Kata Firecracker driver advertises no filesystem sharing, and Kata own documentation tests Docker with QEMU. Reaching a microVM means moving the agent tier off dockerd onto containerd with devmapper, which is a separate decision, so the microVM clauses are marked as intent rather than shipped mechanism.
What ships is gVisor: runtime runsc, a userspace guest kernel intercepting syscalls before the host sees them. Decision 4 turns from a claim into an enforced one, matching the implementation: compose reads the runtime the container actually received and refuses runc, swarm refuses the sandbox label while the node default is still runc, and a lint refuses privileged containers, host namespaces and the docker socket in any role declaring the sandbox. Decision 3 gains the inverse placement constraint, because a node-wide default runtime applies to everything scheduled there. Decision 5 states arm64 as coverage rather than exclusivity.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
include_role sys-stk-full only notifies the compose handler, so the container starts at the handler flush. The isolation assert reads HostConfig.Runtime through container inspect and ran before the container existed. openclaw and homeassistant carry the same assert and hit the same failure earlier in this branch.
The flush comes from the platform parameter rather than a standalone meta task, which also runs the post-deploy steps at the right point. openclaw and homeassistant cannot use the parameter as they stand: both render a config template between the include and the assert, so their container has to start after the render, not at the end of the include.
The MCP TODO records the current state instead of the original stub: discovery is done and openclaw consumes it, while Hermes registers servers through hermes mcp add into its own config store, for which upstream documents neither a file nor an environment contract.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Open WebUI talked to Ollama directly. It now speaks the OpenAI-compatible surface of svc-ai-litellm with a per-consumer virtual key, so a second backend or an external provider becomes a gateway config change instead of a role change. A lint keeps future roles off the model servers.
The gateway advertised a hardcoded model list (ollama/llama3, ollama/nomic-embed-text) while the matrix variant preloads smollm2:135m, so it offered only models the backend did not have and hid the one it did. The list is derived from the ollama preload SPOT now.
The chat spec followed the rename: it gates on litellm instead of the removed ollama service flag, and picks the first non-embedding model from /api/models rather than pinning a variant-specific name, so it exercises the chain rather than a fixture.
Routing openwebui through the gateway pulls both model backends into the closure of every role that consumes it, which pushed nextcloud variant 2 over the memory budget. The budget stays at 32/64 GB; instead the matrix variants of svc-ai-ollama and svc-ai-lmstudio now declare the size their test model needs (1g/2g) while the base keeps the 6g/8g that llama3 and mistral require. The budget guard only saw half of that: it replaced the evaluated role with its variant but counted every dependency from meta/services.yml, a combination no matrix round deploys. It now evaluates every variant-carrying role at the same round index, falling back to variant 0, the way the deploy planner does.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
MCP_DISCOVERED_SERVERS resolves every server-direction role to its internal URL, the bearer the server role minted, and its transport, dropping any server without a token so no client renders a connection it cannot authenticate.
OpenClaw moves from 2026.3.11 to 2026.7.1 because the pinned version has no configuration surface for outbound MCP servers at all: its validator rejects mcpServers, the shipped bundle carries no bundle-mcp, and the ACP path refuses per-session servers while pointing at a gateway config that does not exist there. 2026.7.1 reads mcp.servers, which the template now renders. The gateway command gains its run subcommand because the bare gateway no longer starts anything.
Reachability was the missing half: openclaw sat on its own networks only, so the hub name resolved through the DNS wildcard to the ingress instead of the container. The hub overlay now declares an mcp consumer key, which attaches every role carrying services.mcp.
A probe task runs openclaw mcp probe after the handlers flush and fails the deploy when a configured server does not answer. Without it the previous run reported failed=0 while the client could not reach anything.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The role declared a server-direction MCP service but never provisioned one, and the declared endpoint pointed at /mcp_server/sse, the legacy path. Home Assistant serves MCP under /api/mcp, and its mcp_server integration has no YAML schema, so the config entry only exists through the config-entries flow API.
The provisioning script drives that path over the container loopback: onboard the administrator when the hub is fresh (falling back to the login flow when it is not), mint a long-lived token over the websocket API, then create the mcp_server config entry with it. The token is persisted through sys-token-store, which is why meta/users.yml registers the key; a second role registering a token for the same user only works since the per-key user merge.
The whole path is skipped when a token already exists, so a redeploy costs one lookup. Handlers flush before it runs because the hub container has to exist before anything talks to its API.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>