Every sandboxed command runs in its own pid namespace, where the low
numbers belong to that sandbox's own shell and children. Two places read a
pid as if it named the same thing everywhere, and both blocked translation.
The throwaway container was named after os.getpid(). A run whose cleanup
never ran left `infinito-i18n-libretranslate-8` behind, and the next run,
holding pid 8 in its own namespace, died on `docker run exited with 125:
Conflict. The container name is already in use`. A random token cannot
collide, so an orphan no longer blocks anything.
`deploying()` accepted any live pid as a running deploy. The router writes
build/deploy.pid and never removes it, so a file naming pid 8 refused every
translation run with "A deploy is running" until it was deleted by hand. The
recorded process now has to name the router in its command line; the marker
is verified against the invocation in Makefile:200. /proc is read uncached
because it is per-pid and vanishes with the process.
The test that guarded this asserted the bug: it wrote pytest's own pid and
expected a deploy. It now asserts the opposite for that input, and proves the
positive case with a real process whose argv carries the router path.
Neither fix addresses the root cause of the failures that exposed them: the
sandbox denies 127.0.0.6, so `deployed()` never reaches the running service
and every run falls down the throwaway path in the first place.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ruff reflows the guide_role signature and its two call sites, the same for
three test modules, and the YAML formatter prepends the document marker to
the machine's compose file. These files were created one commit too late to
be caught by the previous formatting pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
test_the_guarded_payload_never_reaches_the_main_cache compared
_MERGED_APPLICATIONS_CACHE against {}, which couples it to everything that
ran before it in the same xdist worker even though setUp resets the cache.
It failed once inside `make quality`, whose four test targets run in
parallel, and passed in four runs since: isolated, whole file, `make
test-unit` and a full unit-plus-lint sweep.
Recording the key set before the call and comparing it afterwards states
what the test name claims and stays true whatever else populated the cache.
A real write by the guarded path still fails it.
Why the cache held an entry in that one run is unexplained. utils/cache has
no on-disk layer and utils/cache/applications.py starts no thread the
thread-local render guard could miss, so neither cross-process nor
cross-thread leakage accounts for it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ruff format reflows long calls and normalises quotes, isort reorders the
imports of the documentation lint package, SIM117 folds nested `with`
statements into one parenthesised block, TC003 moves typing-only imports
under `if TYPE_CHECKING`, the JavaScript linter replaces a `var`, and the
YAML formatter rewraps five role files. None of it changes behaviour.
One entry does more than that: the formatter had reflowed two calls in
test_builder.py so their `# nocheck: cache-read` markers landed on the
closing parenthesis, where they suppress nothing and the cache-read lint
would have reported two real violations. Both calls are hoisted into a
variable so the marker sits on the reading line again.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The replay of a role's "### Production" block moves out of the CI guide
scripts and into the documentation role itself. The block is read off the
rendered page over HTTP, so what runs is what a reader copies, and the role
that publishes the page is also the role that proves it.
scripts/github/guide/{one,compose_deploy,host_deploy}.sh and
cli/build/docs/readme/production.py are gone with the workflow step that
called them. The instructions marker now only adds web-app-docs to the row's
apps; test-e2e-cli discovers the role through its new templates/test.env.j2
and runs files/test/test.sh.
The block runs in a throwaway machine, never on the stack host. compose.yml
starts one service built from files/test/Dockerfile, whose base is
INFINITO_PARENT_IMAGE out of the mounted checkout's .env, so the machine is
the stack host's own distribution and the replay exercises the installation
instead of a prebuilt image. files/test/init.sh adds an init when the base
carries none and keeps the full -Syu that ef0f03ea98 established for arch.
scripts/tests/workspace/base/01_install.sh then prepares the machine before
the block runs.
Nothing is skipped any more. The previous host-install branch exited 0 and
passed silently over 65 of the 178 invokable roles; a block without a
"docker run" is now replayed against systemd in that same machine, with the
git clone dropped in favour of the mounted tree under test.
The target role comes from the new guide_role lookup, which reads
list_invokable_app_ids() rather than matching role-name prefixes, and falls
back to the asking role when the round deploys nothing else. deployment.py
grows running_apps() so both lookups share one definition of what the round
deploys. required_by_coverage.role_is_invokable drops its own walk over
categories.yml and delegates to the same SPOT; measured equal across all 280
roles.
Carried in the same index, because the refactor pass ran over this change
set: library.py splits into sites.py and build_queue.py mixins, builder.py
hands translated_languages to catalogs.py and folds three repeated log-tail
and state writes, and placeholders.py becomes a facade over spans.py,
repairs.py and damage.py. No test changed for those splits, and the two
ref-resolution dicts builder.py carried are replaced by _wanted_ref, which
also stops hashing the deployed tree on every tag build.
Verified: 7132 unit and lint tests pass over the whole tree, the guide_role
lookup renders through a real Templar, the base image resolves from .env,
and compose config validates the service. The replay itself has never been
executed; that needs a deploy of web-app-docs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
DEPLOYMENT_MODE was compared against a quoted literal 219 times across 110
files. IS_COMPOSE_MODE and IS_SWARM_MODE say the same thing once, and
test_deployment_mode_booleans keeps it that way: only the file that defines
them may still compare.
Both are real booleans under this ansible, measured before the swap because
the alternative is silent: a variable that templates to the string "False" is
truthy in Jinja, which is why IS_STACK_HOST is written `| bool` at every use.
These need no filter.
What had to learn the names:
* utils/annotations/task_gate.py, which several lints ask whether a task is
compose-only gated;
* four compose-template lints that recognise their gate by matching the
expression;
* the fixtures that stand in for the play's variables. They derive the
booleans from the mode now instead of carrying them, or a test that
overrides DEPLOYMENT_MODE keeps the old pair and asserts the wrong
branch.
Each replacement was checked rather than trusted: the 206 conditions that
changed in place were evaluated old against new through the Templar under
compose, swarm and host, with no disagreement, and no changed line turned out
to be anything but a condition swap. `!= 'swarm'` became `not IS_SWARM_MODE`
rather than the shorter IS_COMPOSE_MODE, because only the literal translation
stays correct should the mode ever be host.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pins the two failure modes the design turns on: a host with no mesh address is
left reachable rather than stranded, and an address is only read from the host
that owns it, because the swarm reset mirrors one node's host_vars over every
other.
Cosmos, Quick Setup and Credits follow from meta/services.yml, categories.yml
and galaxy_info.author, yet 279 role READMEs carried a copy that went stale
whenever the metadata moved and nobody re-ran make docs. The documentation
build now runs the generators itself, against its own scratch checkout, and
the committed READMEs keep only the prose a person writes.
utils.cache.PROJECT_ROOT derives from the module's location, so putting the
checkout first on PYTHONPATH makes the generator operate on the scratch tree.
i18n.py and builder.py add it; the one remaining coupling, a relative_to over
PROJECT_ROOT in a log line, is gone.
CI extracted the "### Production" block from the committed README with awk.
Stripping the section made guide_deployable() return "" for every role, which
selects no deploy row and turns nothing red: a fail-open regression. It now
asks _is_role_invokable, which matches the old condition exactly (178 roles
either way, both difference sets empty), and cli/build/docs/readme/production.py
renders the block for the two guide scripts. All 178 renders are byte-identical
to what HEAD served, and both scripts' extraction pipelines produce identical
output for a web app, a service and a desktop role.
test_readme.py inverts: the three sections are now a hard failure in a
committed README. readme_md.md documents what each is derived from.
Removed: the docs, cosmos, readme-generate, readme-index and readme-check
targets, the five aliases pointing at them, and docs from make quality.
Verified by running generate_commands() in series against a copy of the
checkout: every command exits 0, no README is left without Cosmos or Credits,
and exactly the 178 invokable roles get a Quick Setup. The ansible_roles step
needs pandoc, which only the docs image carries, so that one link of the chain
is unexercised here; it is unchanged by this commit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every role README carries a ```mermaid flowchart and the documentation
showed it as source. A post-transform now replaces those literal blocks with
<pre class="mermaid"> in html builds, which the runtime picks up; every other
builder keeps the fence as it was.
sphinxcontrib-mermaid injects an inline init script, which this deployment's
CSP refuses, so the runtime and its initialisation are two linked files
instead: mermaid-init.js calls mermaid.initialize with securityLevel strict
and follows the reader's colour scheme. The diagram source is html-escaped
before it is embedded, or an & or < in a node label would break the page.
The runtime is fetched at image build time through ADD --checksum, so a
changed upstream file fails the build rather than shipping. Version and
digest are not ARG defaults, which test_arg_without_default forbids; they
travel meta/services.yml -> vars/main.yml -> compose.yml.j2 the way
DOCS_IMAGE and DOCS_VERSION already do, and an update block registers the
pin with the npm version watcher instead of exempting it.
Verified by building a page with sphinx: the diagram arrives as
<pre class="mermaid"> with its source escaped, a bash fence stays a
highlighted block, and both scripts are linked. A missing mermaid.min.js does
not fail that build, so a local run without the image stays green.
scripts/lint/mermaid.sh keeps validating diagram syntax through mmdc, which
npm installs unpinned. It therefore checks against a newer mermaid than the
11.4.1 readers get, and a diagram using syntax from in between would pass the
lint and render blank.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
untranslated_markup.py and untranslated_yaml.py shared a name prefix in one
directory, which test_prefix_grouped_files reads as a folder in disguise. They
are now extensions/untranslated/markup.py and yaml.py, beside the __init__.py
that extensions/local/ already carries for the same reason.
A module named yaml.py inside a package does not shadow PyYAML, because
imports are absolute: all nine extensions conf.py registers still import, and
the docs-role unit suite stays green.
The unit tests move with them, keeping the parents[9] root derivation their
sibling package uses at the same depth.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
get_merged_applications writes its cache in the last line of the function,
and the re-entrancy guard returns above it. Every lookup('applications') a
template makes while the payload is being rendered therefore rebuilt it:
get_application_defaults deep-copies roughly two hundred roles, then the
merge runs again.
Profiled over one render of tests/integration/roles/test-e2e-cli: 382 calls,
18.765s of 29s, 13.7 million deepcopy calls. That test spent 208s in setUp
for twenty renders and now spends about half.
The guarded payload cannot go into _MERGED_APPLICATIONS_CACHE, because it
carries no users and still holds its volumes. It is memoised on the render
guard instead, which is thread-local, and the finally that releases the guard
drops it, so it lives exactly one render.
Three probes established the preconditions before the change: one cache key
across all 381 nested calls, re-entrancy depth never above 2, and not one of
the 381 payloads mutated after it was handed out. That last one was then
re-measured across the whole suite with the memo made self-checking: 6861
tests and 5133 subtests, no caller wrote to what it was given.
113113e553 removed the same cost between tasks through the carrier fact.
This removes what remains inside a single render.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
accelerated() asked docker info directly, so the variable the project already
carries for this decision did nothing: setting it to 0 in .env left the CUDA
image and --gpus all in place, and test_catalogs_complete_on_gpu kept running.
The variable now wins where it is set, and the daemon is asked only where it
is absent, which keeps every host without the container toolkit behaving as
it did. utils/env/handlers/infinito/gpu_count.py derives it from the same
daemon query but through setdefault, so an operator value survives make
dotenv; that makes it the overridable view of the fact accelerated() used to
establish on its own.
Four call sites follow from it: the stale-container check, the stamp a
successful deploy leaves, the --gpus flag with the -cuda suffix, and the
skipUnless on the catalog completeness test. Turning it off therefore
translates on the CPU, which is what turning the GPU off means here.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
test_brands, test_titles and test_yaml_source spelled out meta/main.yml,
meta/services.yml and meta/info.yml where utils.roles.mapping already names
them. test_file_spot reports exactly that, and it did: the three files were
its only findings in a full make test.
A hard-coded path keeps pointing at nothing once a metadata file is renamed,
and a fixture that writes to the wrong place fails somewhere else than where
the mistake is.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The README generator read its author URL out of the ## Credits sections it
writes itself. .mailmap now folds every address a person commits under into
their @infinito.nexus one, whose local part names their social profile, and
that profile becomes the single point of truth for an author.
utils/meta/identity.py parses .mailmap rather than shelling out to
git check-mailmap, which refuses the worktree the test container mounts.
Cross-checked against git over all 14 identities in the history: no
disagreement.
A new env handler carries the committing identity into the container, which
mounts the checkout but not the contributor's git configuration. Without it
both new tests would skip exactly where they are meant to fire:
* tests/integration/meta/test_commit_identity.py fails when the configured
address does not fold, and prints the .mailmap line to add.
* tests/external/repository/test_author_profile.py probes that profile
live, behind a canary so an unreachable instance is not reported as a
missing profile.
Both look at the current address only. Probing every declared author would be
red for people who have not created a profile yet, and a suite that is red for
someone else's omission stops being read.
Marko Pjevac is declared as a role author but has no address here, so his
Credits line stays unlinked.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A fully pinned priority row now pins the mesh state too, and a priority row
with more combinations than the distro pool has members wraps the rotation
instead of holding one distribution per combination.
The two domains are built apart: docs passes through sphinx, where the
transform withholds a message that is nothing but a name, while core builds
its template straight from role descriptions, categories and menu labels. A
sphinx extension never reaches it, so the same message could be withheld from
one catalog and carried by the other.
build_template now applies the same predicate.
Its effect today is nil: counted directly, none of core's 421 messages is
withheld, and the extract reports 0 catalogs changed. This is an assurance for
what gets added rather than a repair of what is there - a role description
that is only a product name would have slipped through before.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
prose() is built to measure prose, and a name is not prose. Comparing
against it went wrong in both directions: n8n became nn because masking
treats the digit as a span of its own, Infinito.Nexus became empty because
the whole name reads as a domain, and Redis ``cache`` became Redis, which
withheld 131 messages that say more than a name.
_bare() removes only what a page wraps around the name: emphasis at the
edges, a link when it is the whole message, and trailing symbols. The symbol
loop walks the unicode categories rather than a character list, because an
emoji is several codepoints, and it catches the heading that writes Git 🔐.
A link counts only when nothing else is there, or 'See [Mastodon](...) and
more.' would read as a name.
Measured against the previous catalogs: 24 wrongly withheld messages return,
n8n disappears in its three forms, zero warnings. The five reported cases -
[Baserow](roles/web-app-baserow/), [API](roles/web-svc-api/), Git 🔐, Lint 🔍
and n8n - were looked up in the catalog and are gone.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
One predicate now answers what no translator may be handed, and both ends of
the pipeline read it: the extraction withholds the message from the catalog,
the client refuses to send it. What the catalogs keep back is exactly what the
client would have declined.
Two shapes qualify. A message with no word is markup. A message that is only a
name is a label: a brand, this software, a credited person, or a bare URL.
Inside a sentence the name stays protected by the span machinery and the
sentence still needs translating.
Three details each come from a finding. The comparison runs against the prose,
because **Mastodon** is not the string Mastodon and prose() already strips the
emphasis. Case is ignored, because a page writes the name the way its sentence
needs it and **mailu** is still Mailu. The address is matched first, on the raw
text, because masking protects the address of mailto:a@b.c but leaves the
scheme behind and its prose reads as a word.
Measured over the docs domain: 22745 messages fall to 22402 with zero
warnings, and 145 of the 377 removed entries carried a corrupted brand that
was being shipped - Keycloak read Schluesselmantel, OpenProject read offenes
Projekt, micro read Mikrowelle.
SOFTWARE_NAME is read from the group_vars SPOT beside SOFTWARE_REPOSITORY. It
lived there already and no python had ever read it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Mastodon must survive translation, Cleanup Disc Space must not, and neither
the capital nor a dictionary separates them. Measured against en_US-large,
a dictionary rule would have released Mastodon, PostgreSQL, WordPress, Redis
and Firefox for translation, because a brand is deliberately built from an
ordinary word, and it would have protected 'Automated Email Alerts for
Service Failures', because no dictionary holds every technical term.
What separates them is the role's own account of what it deploys. A title is
a brand when every one of its words appears in the upstream homepage or the
image the role pulls: Mastodon sits inside joinmastodon.org, Cleanup Disc
Space inside nothing. 123 titles qualify.
Word by word rather than as one string, because a product spells its parts in
either order and Claude Code lives at code.claude.com; that added two titles
and lost none. Only those two fields, because matching every URL in the role
adds twelve brands and at least eleven mistakes: a reference to
en.wikipedia.org/wiki/Bourne_shell then turns Shell into a brand, along with
Sudo, sshd and GCC.
sys-svc-certbot gains the info.yml it never had, carrying the homepage its own
README already linked, so Certbot becomes derivable. No suppression marker is
needed: the role is not invokable, so the info-media rule does not apply to it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
i18n/names kept a private _sources() that walked the role directories, read
the README heading and added galaxy_info.name. The brand rule needs the same
knowledge, and a second copy is the drift that has already bitten this branch
twice.
The three readers live under utils/meta now. titles also pins the precedence
that was implicit: the README heading wins over galaxy_info.name, because the
documentation is built from the heading. authors stays thin on purpose - the
derivation from the Credits sections belongs to utils/roles/credits, and this
is only the entry point for a reader that wants the names without the URLs.
The titles arrive sorted and deduplicated rather than in read order, which the
alternation built from them does not notice: it sorts by length itself.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A table cell holding only a link has nothing to translate, yet it became a
msgid like any sentence: 2999 of the docs domain's 25742 messages, 11.7
percent. They can never be filled, so they count as pending for good and
skew every completeness figure.
The messages come from sphinx's own extract_messages, which is what the
gettext builder collects. That is not cosmetic: sphinx takes a paragraph's
rawsource, so judging astext() would read 'e2e/' out of [`e2e/`](e2e/),
find a word in it and let the markup through. A test now pins that
difference, because the first version of the test could not see it.
The predicate is has_words, the one the translation client already applies
before it sends a batch, so extraction and translation share one definition
rather than two that drift.
Measured: 25742 messages fall to 22745, no surviving msgid loses its
translation, and 2924 of the 2948 removed entries carried their own source
as their translation.
The import is guarded because the site builds arbitrary refs and one older
than utils/i18n/ cannot supply the predicate; the transform then does
nothing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sphinx prefers nothing: dir/index and dir/README are two independent
documents. The preference lives wherever something maps a folder onto a
page, and this repository had two such places disagreeing.
local.file_headings has always hidden README.md from a directory carrying
an index.rst, while yesterday's resolver saw README only, so a link led to
the page the sidebar hides.
Both now try index.rst, index.md, README.md, README.rst in that order. The
extension is renamed accordingly, and the tuple lives once in the lint
package rather than a third time.
That also repairs two links this branch degraded: roles/ holds an index.rst
and no README, so a README-only rule reported every reference to it as
broken and they were turned into code spans.
Three links stay code spans on purpose. Their text names something the
target is not - a suite, a file - so resolving them would restore a
misleading reference rather than a working one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
join(' ') renders KEY=a b, which the shell reads as KEY=a prefixed to the
command b: the variable is set for that command only and unset afterwards,
so the consumer's ': "${…?}"' guard kills the run over a value the
rendered file plainly shows.
dotenv_quote does not fix it. docker stack deploy passes the value verbatim
in swarm mode, so the quotes would land inside it.
Both sides move to a comma, which the shell does not split on.
The four existing cases could not have caught this: each passes a single
name or an empty string, and one element renders the same either way. The
added case is the only one that sees the separator, and removing IFS makes
it fail.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
apidoc now excludes roles/ and library/ the way it already excluded tests/.
A role name carries hyphens and is therefore not an importable module path,
so automodule reported 44 invalid signatures and rendered nothing; library/
holds ansible modules whose ansible.module_utils imports exist only inside a
play. Measured: 168 generated pages drop to 151, and the 17 that go consist
of a heading and automodule directives on unresolvable paths.
The role page generator fits every section underline to its title. pandoc
counts one character fewer than docutils where a heading ends in a stray
variation selector left behind by a removed emoji. It also escapes inline
markup in role metadata, because one description reads (*.parent) and opened
an emphasis that never closed.
The index generator underlined a 21-character caption with 19 characters.
conf.py adds the tel scheme, without which the phone number in SECURITY.md
is an unresolvable reference, and the extract copies the image assets into
its conf directory the way the site builder does, without which every run
reports a missing html_static_path and favicon.
Four tests pinned values this changes. The fifth, the extension count in
test_conf, asserted nothing the loop below it does not already prove and
broke on every legitimate addition; it now asserts the list is non-empty, so
that loop cannot run over nothing.
With this, make i18n-extract domain=docs finishes with zero warnings under
the -W gate.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Almost every role declares galaxy_info.company as a block scalar. The
generator rendered each entry as '- **key**: value', so the second line
landed in column 0 and ended the bullet list it sat in. 280 of the build's
remaining 354 docutils messages came from generated/roles/*.rst, which is
this generator's own output; folding every value onto one line clears 277
of them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The lint accepted a link that resolved to a file or a directory. myst
builds a document reference only when the target is_file(), so a link to
a directory without a README is a bare file listing on GitHub and a dead
cross-reference in the documentation build. It now reports which of the
two reasons applies.
The tightened rule found 41 links. Where a README backs the directory,
bd15eae9fa's resolver carries the link and it stays. Where none exists,
the link becomes a code span rather than an invented README: 15 of them
point at a role's meta/addons/, whose addon table already sits below the
sentence, and the rest at code directories such as utils/ or
cli/administration/deploy/runner/.
The changed sentences are msgids, so these entries return to the
translator on the next extract.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nine package shims listed dir(__main__) and lifted every public name into
the package namespace, so each of them re-exported its imports. pathlib.Path
became a member of nine packages, and sphinx found five targets for a bare
Path annotation: 320 ambiguous cross-references in the documentation build,
down to 10 with this change.
public_names() honours a declared __all__ verbatim and otherwise keeps the
names whose __module__ is the module itself. Nine call sites shared the same
expression, so it lives in one place now.
This narrows a package's public surface: __all__ shrinks and the shim no
longer carries its imports. Every importer in the repository pulls a locally
defined symbol - EXIT_NO_SUCH_APPLICATION_ROLE, find_application_ids,
build_mappings, output_graph - and none pulls Path or Any.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both extensions landed in bd15eae9fa without the mirrored unit test the
repository lint requires, which left that lint red.
The resolver's cases include the mistake its first version made: it
filtered on refdomain == 'doc', while myst builds a document reference
only when the target is_file(), so a directory arrives with refdomain
None and the transform did nothing at all. Reintroducing that filter
fails three of the six cases.
The parser's cases pin what the build relies on: one yaml-highlighted
literal block, no block quote, a title from the filename, and the orphan
docinfo as the first child, which is the only place sphinx reads metadata
from.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
catalog.update() defaults to fuzzy matching: for every changed msgid it
searches all removed messages for a similar string and carries that
translation over as a fuzzy entry. The search is quadratic. Measured on
one catalog, it costs 11.01s for the 421-message core domain and did not
finish within 10 minutes for the 25182-message docs domain, per language,
across 20 pool workers.
The carried-over value is dead. Four places read the fuzzy flag and three
exclude it: translations(), the API reader and the docs builder. The
fourth, pending(), treats a fuzzy entry as untranslated, so the next
i18n-translate run overwrites it anyway.
With the search disabled, make i18n-extract domain=docs finishes in 2m4s
wall for 47 catalogs, of which user/real 1.56 shows almost all remaining
time is the single-threaded sphinx build. Checked msgid by msgid on two
catalogs: no surviving entry lost its translation.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The server answers a failed call with its input, sometimes lightly
reworded and sometimes with a word repeated. Both shapes pass every
existing criterion: the protected spans survive, the markup count
survives, it is no echo, and truncated() only watches the short side.
stutters() reports a word the translation repeats three times in a row
and the source does not. untranslated() reports a non-Latin target whose
prose is at least 90% ASCII words, which needs the catalog language, so
harms() and unmask() now take it and client.py passes its target through.
Measured over 580079 translated entries: 3650 stutters, 611 still in
Latin script, 2 both, 4259 newly reported. None of the source messages
repeats a word three times itself.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The client seeds every entry with its own source and replaces only the ones that carry words, so when the server answers with its input the seed stays, gets the libretranslate marker, and is neither pending nor damaged. 542 entries across 439 msgids were plain English while counting as translated, and nothing would ever retry them.
harms() now rejects an identity mapping once the source carries 30 letters outside its protected spans. prose() is has_words' inner half, extracted so the threshold measures what the translator actually had to translate; has_words keeps its signature and behaviour.
The floor is measured, and two earlier shapes were wrong: a raw length of 30 condemned '', and counting prose characters condemned backtick lists of role names whose 75 characters were commas. Counting letters leaves 439 msgids, every one of them translated by at least one other catalog, led by ar with 327 against de with 25 - if identity were legitimate it would cluster in the languages closest to English, not the furthest.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The client seeds every entry with its own source and only replaces the ones that carry words, so a word-free msgid keeps the source verbatim. That was harmless until 9679fa4221: tighten and resegment judge a translation against a rule rather than against its source, so an identity mapping became condemnable as soon as the source itself broke the rule. The gate then reported a source defect as translation damage and prune could not help, because the next run seeds the same source again.
Return False when the translation equals its source. A source that breaks a rule is what the source-side lints are for.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fetch() requested latest and run_builder() requested deployed once at startup, so a release tag was built on demand and then never revisited. A tag carrying a languages.json from before the translated-site split stays servable, nothing ever requests it, translates() stays false for every code and /v1.0.0/de/ answers a hard 404 forever.
_refresh_stale_sites() asks for every version that is already servable; request() checks _current() itself, so only a genuinely stale one is queued and an unbuilt tag stays on demand instead of triggering a mass rebuild.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both modules pin every environment variable compose_file_args() reads. Adding Profile.gpu_reservable() in 4d09286d8b added INFINITO_GPU_COUNT as a reader without pinning it, so the expectation silently followed the ambient environment: green on a shell that has no such variable, red in the test container where BASH_ENV injects .env and this host reads 'all'. That was eight of the unit failures in the gate.
Pin it to 0 in the three affected fixtures and add the two cases the gate never had: no GPU means no override, a non-zero count adds it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
request(..., background=True) skipped a language as soon as translation_servable() found an index.html, which is an existence check with no ref in it. A language was therefore built exactly once: every later commit advanced the English site while every translated site stayed frozen on the commit it was first built from. The eager loop this replaced rebuilt them all, so the freeze arrived with f5eff500c1.
_publish_site already stamps the ref beside each translated site and nothing ever read it. translation_ref() reads it and _translation_current() compares it against _wanted_ref(), which also replaces the eager {LATEST: head, DEPLOYED: snapshot_ref()} dict so an unreadable snapshot no longer kills the builder while building an unrelated version.
Two more from the same review: build_language wrote into the version's state file, so a failing language reported the whole version as failed and leaked its sphinx log into /api/versions; it now uses its own version:code key. And the language index was written after the site was published, so a failure in between left a current site with a stale index, which _current() rejects forever and fetch() would rebuild on every tick; the index is written first.
library.py had grown past the 500-line cap, so the build machinery moves into builder.py as a Builder mixin. An AST diff proves every function, method and constant is byte-identical apart from _write_json -> write_json, and the six build-focused test cases move to test_builder.py with the count unchanged at 16.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
test_i18n_catalogs.py:125 and :141 report a translation whose emphasis carries a space or whose sentence boundary was swallowed, and the first one's failure message says to run prune and translate again. prune clears through damaged(), which did not know either criterion, so those entries survived every prune and the advice was impossible to follow.
Add both to harms(). The writer does not get stricter: unmask() applies tighten and resegment as repairs before it asks harms(), so a freshly written entry satisfies them by construction, and a test pins that. Measured over every checked-in catalog, 581220 translated entries: 599 condemned before, 599 after, 0 newly condemned.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ensure() creates the agent's network and attaches the broker to it in the next
statement. Under swarm that network is swarm scoped: the control plane holds it
the moment create returns, so the GET inside connect answers, while realizing it
on a node is the allocator's asynchronous work and connect is node local. The
attach therefore ran against a network the node did not have yet and took a 404.
Run 36107421422 shows both halves. The broker logged agent_failed with 'POST
/networks/<id>/connect -> 404: network not found' for every owner, and dockerd
on the same node logged 'Failed to find network <id> on node' once per node, for
all three. Every agent request then answered 502 and the drill reported the two
agent services missing. The compose row passed throughout, which fits: a bridge
network is local and usable as soon as it exists.
connect now retries while the engine answers 404, up to realize_timeout, and
re-raises anything else at once. EngineError carries the status for that, set at
the single raise site in call, so the decision reads a number rather than
matching the message text. The wait is scoped to the one condition known to
clear by itself: a 403 from the socket proxy still fails immediately, and a 404
that outlives the window still fails, because then the network really is gone.
Three cases pin it in both directions, and each fails against the previous
engine.py: a 404 that clears is waited out, a 404 that persists raises at the
deadline, a 403 raises on the first attempt.
Not verified: no swarm run has exercised this. realize_timeout is 120 seconds
from a single observed window of at least five, and it converts a structurally
missing network from a fast failure into a slow one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The role answered one contract, POST /v1/systemone, under the name of the
single implementation that happened to serve it. Renaming it to svc-ai-s1
leaves the contract where it was and lets the flavor decide who answers:
variant 0 runs laya, variant 1 runs the GLiFormer server the role carried
before, so a round proves the contract against both rather than against
whichever one ships as the default.
Reading laya 0.3.20 rather than reasoning about it corrected two defects that
would have hit variant 0 on its first deploy. LAYA_MODELS takes the short
checkpoint names, not repository ids: router.py maps english to
convaiinnovations/laya, so the entry carries both and hf download takes the
repo while the env takes the name. And laya serves @app.get("/health") where
jeff serves healthz, so the base healthcheck moves to health and variant 1
overrides it back.
services.s1.gpu states the intent and 00_core.yml resolves what the host can
actually do, requiring /dev/nvidia0 and /usr/bin/nvidia-container-runtime
before it builds a CUDA image. Both are needed: with the device alone the
daemon has no runtime to attach it with, so a GPU machine still builds the CPU
image until the toolkit arrives. The flag therefore ships true and stays inert,
and the CPU render is byte identical to the one before this commit.
templates/Dockerfile.j2 becomes files/Dockerfile plus files/shell/build.sh and
files/shell/entrypoint.sh, in the shape svc-ai-agent-broker already uses: ARGs
supplied by build.args, the context staged through render_replicated_templates.
The branching is shell rather than Jinja, so shellcheck reads it and it runs
outside a deploy. Executed with pip, apt-get, hf and python stubbed, all four
flavor and GPU combinations emit what the template emitted.
meta/info.yml gains the video that explains what a System One model is, which
retires the marker that claimed none existed.
Verified: 519 lints with 381 subtests, 196 unit tests, the four-way build.sh
execution, and the five-row truth table for the GPU gate. Not verified: no
laya image has been built and no container has run, on either deployment mode.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
unmask() accepted a translation as soon as protected_spans matched, while damaged() also rejected it for a dropped name, a halved body or changed markup. Three of the four criteria lived only in the gate, so every run wrote entries the gate then condemned, make i18n-prune emptied them and the next run wrote them back. It never converged: ko carries 331 such entries, 3.7 percent of what it has translated, against 0.1 percent or less for Latin scripts.
Introduce harms(source, translation) in placeholders.py as the single source of truth and call it from both ends. truncated() and structure() move there with it, since translate.py already imports placeholders and the other direction would be a cycle.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
build() ran sphinx once per translated language before returning, so a deploy blocked for roughly 30 minutes per language. With 29 translated catalogs German sat eighth and its site appeared after about three and a half hours, which is why the language spec polled /deployed/de/ into a 404 for its whole run. The server already answered an unbuilt version with 202 and a build request; an unbuilt language got a bare 404.
Give languages the same treatment. request() takes a code and writes a version:code marker, build_language() builds exactly one from its own checkout via the extracted _checkout(), and the server answers a translated but unbuilt language with 202 while an untranslated one keeps its 404. No language is dropped: build() now queues every translated one into queue/background/, which next_queued() drains only after the lane a visitor is waiting on, so the requested language overtakes the backlog.
languages.json grows a translated list beside known, and _language_index() reads a pre-split file as known-only so an existing volume keeps naming its languages instead of rendering an empty switcher.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
--include filters the generated inventory to the ids it is given literally, so a provider pulled in through the dependency closure runs without joining group_names. svc-ai-agent-broker's README therefore documented a deploy that stops on its own assert: litellm comes along, nothing it can serve does, AI_AGENT_MODEL resolves to '' and 00_core.yml refuses the agents. Run 36064589578 shows it as 'Including only application_ids: svc-ai-agent-broker' followed by the assert.
meta/services.yml gains guide_companions, read through utils/roles/meta_lookup.py beside get_role_run_after, and the generator joins it onto the role id as application_deploy_ids for the three places the Quick Setup block spells the id out. A role without companions renders byte-identical, which the README lint confirms across every role.
This declares where e74a585402 derived: a role asking for networks.internet.dns names it in its own tasks, so the generator can read it. A companion cannot be read that way. The broker's tasks state only that the model must resolve, and deploying a backend is one of two remedies, the other being a provider key on svc-ai-litellm; which one the documented path takes is a decision rather than a derivation, so it is declared and the fail_msg names both.
Declaring services.ollama on the broker instead was tried and reverted: it pulls svc-ai-ollama into the closure, which a probe confirms, but the literal include filter leaves it out of group_names all the same, so the model server would run while the assert still fired.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The healthcheck is http_status on healthz, which answers as soon as the HTTP server binds, while the model loads on the first real question. Everything that waits on the healthcheck therefore treats jeff as ready during a window in which it cannot answer. svc-ai-litellm's router hook allows one attempt of services.jeff.request_timeout and refuses to route on any error rather than substituting a decider, so a consumer arriving in that window gets a 400 saying the system_one strategy could not reach a decision with TimeoutError: timed out. Run 36064589578 carries that twice in litellm's container log, after which the same route answered in 6.7 seconds.
01_manager_ops.yml puts one real choice question after the handler flush and repeats until it is answered, in the shape svc-ai-litellm already uses to warm its chat model: resolve the hosting node and container id, then pass the script in through python3 -c. Raising request_timeout or retrying inside the hook would only move the window, and the refusal itself is correct and stays.
The script reads the port, the alias and the key from jeff's own environment, so nothing reaches the command line that a task echo would carry. It checks that the answers block holds the question it asked, because a 200 without an answer means the model did not decide and reporting success there hands the cold load back to the first caller. The mirrored test pins that exit and pins the budget above request_timeout, since a warm-up on the router's budget would time out exactly where the router did.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The routing probe cross-checks usage.prompt_tokens against what it sent, because that is the one number in the exchange the backend produced rather than this deployment declared. A mock route answers from mock_response and never reads the prompt, and litellm sets usage unconditionally from DEFAULT_MOCK_RESPONSE_PROMPT_TOKEN_COUNT, which is 10 in the pinned main-v1.77.7-stable. Every mock answer therefore reported 10 of 8192 and failed as a silent truncation, which is what run 36064589578 hit in both litellm rows.
The exemption sits after the declared-window check, so a mock routed a prompt its own window cannot hold is still a routing failure; only the truncation number is dropped. MOCK_ALIASES comes from AI_MOCK_ALIASES rather than a second derivation of the same list.
Pinned by three cases, including the placement: moving the exemption above the window check makes test_a_mock_is_still_held_to_its_declared_window fail. The reason string of a neighbouring case claimed the opposite of what the pinned image does and is corrected.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
sys-svc-compose writes compose.ca.override.yml for exactly lookup('ca_injected', application_id), which since 1254e27c2d also answers true for a service that declares ca_client and only calls the self-signed endpoint. compose_file_args rebuilt that predicate instead of asking it, and its copy still demanded a domain, so for a domainless ca_client the file was written and never reached --compose-file. Swarm takes its file list from that lookup; compose finds the file on disk by itself, which is why only the swarm jobs of run 36064589578 failed while the compose job passed.
The broker's container in that run carries no SSL_CERT_FILE and no CA mount, while openwebui, same job and same node, carries seven CA env vars and four binds. Every outbound call to Keycloak then fails the handshake, which the CLI test reports as 503 and Open WebUI relays as HTTP 400.
Two corrections in ca_injected itself. Its templar.template() call was dead: as_str strips the TrustedAsTemplate tag and ansible-core 2.19+ refuses to render an untrusted string, so the line could not resolve what it advertised, and tls.py and cert.py never template TLS_MODE either. And the ca_client branch short-circuited on the global TLS_MODE, skipping resolve_mode's per-app server.tls.mode override; it now applies only where there is no domain and so no per-app answer to get.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The router settings become one mapping. Nine scalars sharing a router_ prefix were a dict spelled out in key names, and the sampling section about to arrive would have added five more. Carried along: fifteen lookups in vars, three variant overrides, four test files, and the two rejection messages that name the settings path, which would otherwise have pointed a reader at a key that no longer exists.
On a configurable trigger, seconds or requests or probability, the same request goes to several eligible routes at once. jeff is shown the replies rather than the routes that wrote them, with the aliases hidden behind option labels so the verdict rests on the text. In shadow mode the caller keeps the route already decided; in serve mode the winning reply is handed back through mock_response, so the fan-out is the answer path and no further call follows.
Each comparison writes one row per offered route to the proxy's own database, grouped by a request class of token band and capability demands. Grouping finer would spread the evidence too thin to decide, coarser would let a verdict about a vision request speak for a plain chat. A route that raises is left out rather than recorded as a loss: failing to answer is a fact about the moment.
The decider now also sees the price of this request, the measured rate, the quota and prompt caching, plus how each route fared before. A figure the catalog does not state is left out rather than defaulted, because a decider cannot tell an absent number from a zero one.
Sampling ships off. Every history and comparison path is caught wide and logged: neither may ever fail a route.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2aa7325eca put role names and product titles into the spans mask() produces, and
unmask hands those same spans to resegment. That was safe for every span the
protection had before, because each ends in a delimiter, so a letter right
behind it can only be the text a lost boundary was taken from. A bare name ends
in a word character, and Bengali writes `Baserowo` where the source says
`Baserow`, so the substring search found the name inside the longer word and put
the punctuation in the middle of it:
resegment('Baserowo-r shathe', ('Baserow',), 'Adapter to Baserow: ...')
-> 'Baserow: o-r shathe'
Names are skipped. The repair still fires for the spans it was written for, and
the 85 entries the boundary lint reported across et, fi, eu, cs, ja and bn drop
to none.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Measured over the entries no rule reported, a machine translation loses the
sentence's full stop in 12417 cases, the capital of its opening word in 2139,
and opens a run of spaces around a protected span in 610. None of them touches
the meaning, so the answer is worth keeping and worth repairing rather than
discarding.
The full stop goes missing when a closing bracket moves in front of a protected
span and takes the punctuation behind it along, which leaves the sentence
running into the next one. resegment already puts back a boundary that went
missing mid-text; terminate does the same at the end.
collapse leaves a run the source carries itself alone, because an aligned block
in a code sample means its spacing.
Truncation cannot be repaired, only reported, and joins the damaged set with a
floor of 120 characters: `Situation` becomes `Lage` and halves its length while
saying the same thing, so a shorter entry proves nothing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nothing stopped a machine translation from rewriting the identifiers the
documentation is about. Measured over the catalogs, 4836 of 25166 role name
mentions came back altered and 9131 of 33337 title mentions, among them
`(Unbound, etc.)` turning into `(Ungebunden, etc.)` for a DNS resolver and
`Snipe-IT` losing its non-breaking hyphen to an ordinary one. Emoji went the
same way.
The names come from the repository itself: the role directories, the first
heading of each README and galaxy_info.name. A hyphenated role name is never an
English phrase and holds wherever it appears. A title is protected only inside a
sentence, because at a sentence start every word is capitalised and the capital
stops telling a product from a common noun: the heading `Documentation` is meant
to be translated, `Keycloak` in the middle of a clause is not. A dictionary
cannot make that call either, since `Unbound` and `Confluence` are English words
and product names at once.
Masking and comparing had to be separated. German capitalises every noun and
compounds a name with what it qualifies, so `shell extensions` legitimately
becomes `Shell-Erweiterungen`, which a set comparison reads as a surplus span
and a word boundary ending at the hyphen reads as a loss. Names are therefore
masked out of the request but checked for presence rather than counted, and
protected_spans keeps the equality semantics it had for the structural rules.
This reports 21352 entries whose translation dropped a name or an emoji. The
finding is correct and the lints that surface it stay red until
`make i18n-prune` empties them for the next run.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>