release(v4.8.1): bump framework and eight skills to 4.8.1 in lockstep; archive PR #43 reviews (two rounds)

Issue #26: one .mam.env parser for the wrapper and the Python tools,
decimal integer knobs, tests on a local MQTT broker. Merge-workspace
review: round 1 (@b58edac) claude agy PASS, codex NOT PASS (R1-R4), grok
no verdict (its late report, archived as report-c9ed5b14.md, found R1 too);
round 2 (@0eec795) 4/4 PASS. Reports and round-2 identity sidecars go to
.agents/reports/<session>/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
This commit is contained in:
GodopuandClaude Opus 5.5 committed 2026-10-02 00:23:22 +09:00
1 parent 0eec795cd1
commit 2e90c809d3
28 files changed
+922 -26

No files matched your search

@@ -0,0 +1,96 @@
# Review: PR #43 (fix/issue-26) @ b58edac
- Role: Reviewer (grok)
- Identity: base `e1eb22bd59ce825e3a22e86433c30f7ffd833810`, head `b58edace115205500c1197d9ce59cb5c6d724893`, tree `14b670fbfc82dc9179cab43cf53eef920a3d6ffa`
- Verified at `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261001T131705Z-a60092/1` (the assigned directory was gone at the start of this pass; the same commit was checked out there again, and `HEAD` / `HEAD^{tree}` match the brief)
- `git diff --check e1eb22b...HEAD` is clean. Main stayed at `e1eb22b`. No product file was edited.
The two loaders share `envfile.py`, the integer rule matches on the knobs that reach bash arithmetic, and the in-process broker covers the client paths this repo uses. One failure mode is open: a `.mam.env` that exists and cannot be read is a warning, and the wrapper keeps going.
## 1. Parser
`mqtt_common._parse_env_file` (`mqtt_common.py:98-101`) and the wrapper both go through `envfile.parse` / `envfile.apply`. The same input therefore yields the same pairs.
Checked against the rules in `envfile.py:3-9` and a direct `parse` of temporary files (no workspace `.mam.env` was read):
| Input | Result |
|---|---|
| CRLF | `A=1`, `B=two` after quote removal; no warning |
| One matching quote pair | `" spaced "` becomes ` spaced ` (internal spaces kept) |
| Mismatched quotes | `"a'` stays `"a'` |
| Extra `=` | `E=a=b=c` keeps value `a=b=c`; `EMPTY=` is `""` |
| `$(...)`, `${...}`, backticks | kept as literal text |
| `export MQTT_PORT=1883` | skipped, warning `invalid key` (the key is `export MQTT_PORT`) |
| BOM on the first line | that line is skipped (`invalid key`); the next line loads |
| Line with no `=` / a key that fails `^[A-Za-z_][A-Za-z0-9_]*$` | skipped with the line number; the value is absent from the warning (`envfile.py:60-65`) |
| Duplicate `A=1` then `A=2` | both pairs are returned; `apply` (`envfile.py:80-83`) sets the first and leaves the second, because the key is then already in the environment |
`export KEY=` is the shell prefix the old `source` accepted. Both loaders now skip it. That matches the shipped files: `.mam.env.example` assignments are `KEY=value`, and `deploy/install.sh:513` is an unquoted heredoc, so `$MAM_PORT`, `$MAM_CLIENT_PREFIX`, and `$HOME` (`install.sh:520-526`) are expanded by bash before the parser sees the file. Uncommented example lines contain no `$` or backticks. A leading BOM is not in the example; both loaders skip that first key together.
`tests/test_issue26_envfile.py` (the two real loaders, reserved-variable set, and `shlex` quoting) passed inside the run below.
## 2. Wrapper eval
The wrapper runs `python -I envfile.py --shell "$TARGET_ENV"` and evals stdout (`multi-agent-mux-delegate-job:41-45`). `-I` is safe: the module is stdlib only (`os`, `re`, `shlex`, `sys`, `typing`), and `python -I` on the PR 4.3 venv imports that stdlib. Every printed line is `export NAME=<shlex.quote(value)>` for a key that already passed `_KEY.fullmatch` and `is_reserved` (`envfile.py:62-67`, `97-98`). A probe value `BASH_VERSINFO[$(touch …)0]` came back single-quoted; the marker file was not created, and the warning did not contain `touch`.
Missing interpreter: rc 1, stderr `ERROR: cannot read <path>`, usage text absent. That matches `test_a1_an_unreadable_parser_fails_loudly` (`tests/test_issue26_envfile.py:139-146`).
Unreadable file, this pass: mode `000` regular file, `MAM_ENV_FILE` pointed at it, `bash wrapper --help`.
- `envfile.parse` catches `OSError` and `UnicodeDecodeError` and returns a warning (`envfile.py:52-53`).
- `main` prints that warning and returns 0 (`envfile.py:94-99`).
- The wrapper's `|| exit 1` (`multi-agent-mux-delegate-job:44`) runs only when that process is non-zero.
- Observed: rc 0, stderr `WARNING: <file>: cannot read (PermissionError)`, stdout still contains `submit`.
The wrapper is `set -euo pipefail` (`multi-agent-mux-delegate-job:15`). The previous body was `set -a; source "$TARGET_ENV"`, so a permission error ended the process. The new path loads nothing from that file and continues. With no broker variables already in the environment, later clients use the product default broker.
`test_a1_an_unreadable_parser_fails_loudly` covers a missing `DELEGATE_JOB_PYTHON` only. It does not cover a file `open` that raises.
Alternative: when `parse` hits `OSError` or `UnicodeDecodeError`, `main` returns 1 (the warning text can stay). The wrapper already treats a non-zero parser as `ERROR: cannot read` and exits 1. Per-line skips stay warnings and exit 0. `mqtt_common._parse_env_file` calls `apply` and ignores the CLI status, so the Python loader's warning log stays as it is.
## 3. Precedence
A variable already in the environment is left alone (`envfile.py:9`, `envfile.py:80-83`, wrapper comment at `multi-agent-mux-delegate-job:37-40`). `MESSAGING.md:366-368` already stated that for the Python loader. The wrapper used to overwrite it on every `source`. Callers that need the file's value must leave the variable unset.
- Loop: `LoopRunner.delegate_job` passes `dict(os.environ)` (`runner.py:548-557`). Keys the loop process already loaded stay for the whole process. A process that has not set them still receives the file.
- Worktree create adds `MAM_ENV_FILE` pointing at the main checkout's file and does not itself export `MQTT_*` (`worktree_prepare.sh`).
- The remote runner loads through `mqtt_common`, which already used this precedence (`mqtt_common.py:44-46`).
- The test suite depends on the new order: the session broker is injected with `MQTT_BROKER` / `MQTT_PORT` / `MQTT_TLS`, and a `.mam.env` on disk must not replace them (`tests/conftest.py:1568-1575`).
`.mam.env.example` does not say that an already-exported variable wins. The loader docstring and the wrapper comment do.
## 4. Integers
`mam_int` / `mam_int_knob` (`lib.sh:2345-2365`) and `_positive_int` (`runner.py:71-81`) share one rule: surrounding blanks, digits only, decimal (`10#` in bash, leading zeros stripped before `int` in Python), at most 9 significant digits, then a per-knob minimum. Invalid environment values warn and use the default. Invalid wrapper flags exit 1 (`multi-agent-mux-delegate-job:194-199`).
On this host's bash 3.2, `$((0700))` is 448 and `$((0900))` errors, while `[ 700 -eq 0700 ]` is true. The `NOT_ARITHMETIC` rows that say `[ -ge ] (decimal)` match that `[` builtin. The knobs that reach `$(( ))` or `(( ))` in the wrapper and `lib.sh` go through `mam_int`: job budget, heartbeat, misses, ack timeout, inject grace, and the `SKS_*` deadlines including `SKS_QUIESCENT_TRIES` at the `send_keys_safe` call. Minimum 0 is the "check once / do not wait" settings (`MAM_INJECT_ACK_GRACE_S`, `delegate_wait_started`'s ack, `SKS_DIALOG_TIMEOUT`, `SKS_BUSY_TIMEOUT`, `SKS_DELIVERY_GRACE_S`, `SKS_QUIESCENT_TRIES`). Minimum 1 is the budget and the liveness counts, where 0 would close a job immediately.
`MAM_WAIT_GRACE_S` (`runner.py:627-628`), `MAM_CHILD_WAIT_S` (`runner.py:748-749`), and `MAM_JOB_TREE_MAX` (`runner.py:752`) still use `isdigit`/`int`/`float`. They stay in Python, so they never hit bash octal. This diff does not claim to move them onto `_positive_int`. `MAM_CHILD_WAIT_S=abc` still raises `ValueError` inside `wait_for_descendants`. That is unchanged behavior, and it is outside the bash-arithmetic fix.
## 5. Local broker
`tests/_local_mqtt.py` listens on `127.0.0.1:0` with daemon threads and closes the listen socket and connections in `stop`. The session fixture (`tests/conftest.py:1568-1584`) points `MQTT_BROKER` / `MQTT_PORT` / `MQTT_TLS` at it and drops the auth and certificate variables for the session, restoring them afterwards.
What the repo's paho 3.1.1 clients need is implemented and checked by `test_c0_make_client_speaks_3_1_1_wildcards_qos1_and_retained`: CONNECT levels 3 and 4 with CONNACK, `+` and `#` subscriptions, QoS 1 PUBACK only after `_route` (`tests/_local_mqtt.py:196-199`), retained replay to a late subscriber, delete on an empty retained payload, and UNSUBSCRIBE. `test_c0_holds_when_the_broker_is_slow` delays CONNECT, UNSUBSCRIBE, and route so a test that reads broker state before the ack fails. QoS 2 is refused. There is no TLS, no auth check, no persistent session, and no QoS 1 redelivery; the module docstring says so, and the product paths under test do not use those.
The moved tests keep their product assertions. acceptance1 and acceptance5 also require the subscriber log, and acceptance5 requires the runner log plus `dispatch_verified` / `dispatch_fingerprint` on the audit row. The "public broker did not deliver" skips on those paths are gone (`test_issue18_liveness_contract.py`, `test_issue19_subscriber_daemon_lifecycle.py:417-424`). `test_c2_no_test_names_the_public_broker_or_strips_the_local_one` scans `tests/*.py`. Caller-supplied `MQTT_PORT` still overrides the fixture, so `test_t1_4_broker_unavailable_fail_closed` can still name an unreachable port.
Ran without exporting `.mam.env`: the 10 broker tests from issues #6 and #19 that are outside the brief's file list, 10 passed in 100.83s.
## 6. The "12 baseline purge failures" claim
`tests/test_issue10_13_purge.py` is not in `e1eb22b...HEAD`. Run alone on this host, `HOME` set to a temporary directory, session-id variables unset: 23 passed in 12.69s. The claim that 12 of 13 failures are this file's baseline on this host does not reproduce. The full suite (the brief's ~75 minutes) was left to the orchestrator, so this pass does not confirm or deny a separate total of 13.
## 7. Design boundary
`mam_int` reads a decimal for bash arithmetic. It does not choose `deadline_timeout` versus `wall_clock_timeout`, extend a budget, or decide a stall. Stall detection stays in `stall_watch.py` and publishes `progress` only. Closing stays on the existing clocks. The new bash is the reader plus `eval` of quoted `export` lines.
## Tests
From the review tree, with `AGENT_PYTHON_BIN` set to the PR `.venv` and `MQTT_*` / `MAM_ENV_FILE` unset in the parent (the session fixture starts the local broker):
`tests/test_issue26_envfile.py tests/test_issue26_ints.py tests/test_local_mqtt.py tests/test_issue18_liveness_contract.py tests/test_issue33_budget.py tests/test_issue30_stall_watch.py tests/test_tier1_unit.py`
228 passed, 1 skipped, 529.36s. Plus the 10 broker tests in section 5.
[VERDICT: NOT PASS]
@@ -0,0 +1,14 @@
{
"loop_id": "loop-20261001T140030Z-34381f",
"base_sha": "e1eb22bd59ce825e3a22e86433c30f7ffd833810",
"head_sha": "0eec795cd1b46f7a28b9a1cc87ef8577619a731f",
"tree_sha": "584eac551f880232896b898cc43156f28ae1bacb",
"review_commit": "0eec795cd1b46f7a28b9a1cc87ef8577619a731f",
"reviewer": "reviewer-grok-01",
"job_id": "fe93e3f2",
"verdict": "PASS",
"transport": "herdr",
"evidence": "review-tree",
"isolation": "worktree",
"verified_at": "2026-10-01T14:27:22Z"
}
@@ -0,0 +1,121 @@
# Review: PR #43 (fix/issue-26) round 2 @ 0eec795
- Role: Reviewer (grok). Full range `e1eb22b..0eec795` (this reviewer had no recorded round-1 verdict on the previous job).
- Identity: base `e1eb22bd59ce825e3a22e86433c30f7ffd833810`, head `0eec795cd1b46f7a28b9a1cc87ef8577619a731f`, tree `584eac551f880232896b898cc43156f28ae1bacb`, 7 commits, clean worktree.
- Verified at `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261001T140030Z-34381f/1`. Main stayed at `e1eb22b`. No product file was edited.
- `git diff --check b58edac..HEAD` is clean. That commit is 7 files, +128/−19: `lib.sh`, `envfile.py`, `mqtt_common.py`, the delegate-job wrapper, `.mam.env.example`, and the two issue-26 test files.
`0eec795` closes the round-1 fail-open: a `.mam.env` that exists but cannot be read or decoded is `EnvFileError`, the `--shell` command exits 1, and the wrapper exits 1. NUL lines are skipped by the shared parser. `mam_int` uses ASCII whitespace and digits. An explicitly empty integer flag is refused.
## 1. Parser agreement
`mqtt_common._parse_env_file` (`mqtt_common.py:98-104`) and the wrapper both call `envfile.apply` / `envfile.parse`. One table, both loaders.
Rules in `envfile.py:3-12` and `.mam.env.example:14-25` match the code:
| Input | Result |
|---|---|
| Blank line, `#` comment | skipped (`envfile.py:64-65`) |
| Surrounding ASCII space, one matching quote pair | stripped; internal spaces kept (`envfile.py:80-83`) |
| Mismatched quotes | kept |
| Extra `=` | value keeps every `=` after the first (`envfile.py:69`) |
| Empty value | kept |
| `$(...)`, `${...}`, backticks | literal |
| `export KEY=...` | skipped, `invalid key` (the key is `export KEY`) |
| Reserved bash variables | skipped with the name, never the value (`envfile.py:77-79`) |
| No `=`, or a key outside `^[A-Za-z_][A-Za-z0-9_]*$` | skipped with the line number (`envfile.py:71-76`) |
| CRLF | `splitlines()` drops the CR |
| A line containing a NUL byte | skipped, `{path}:{n}: skipped (NUL byte)` (`envfile.py:66-68`). The warning has no value. |
| Whole-file `OSError` or `UnicodeDecodeError` | `EnvFileError: {path}: cannot read ({type name})` (`envfile.py:57-61`), `from None` so the message carries no bytes from the file |
| Duplicate keys | both pairs returned; `apply` (`envfile.py:91-94`) sets the first, because the key is then already in the environment |
`export KEY=` is what the old `source` accepted. Both loaders skip it, and the example says so (`.mam.env.example:21-23`). `deploy/install.sh:513-530` is an unquoted heredoc, so `$MAM_PORT`, `$MAM_CLIENT_PREFIX`, and `$HOME` expand before the parser sees the file. Uncommented example assignments contain no `$` or backticks (`test_a5`).
A leading BOM is outside the example. `open(..., encoding="utf-8")` does not strip it, so line 1 fails the key check and the next line loads. Both loaders do that together.
`tests/test_issue26_envfile.py` locks the two real loaders, including mode `000` and broken UTF-8 (`:154-178`, skipped only when euid is 0) and the NUL line (`:183-193`).
## 2. Wrapper eval
The wrapper runs `python -I envfile.py --shell "$TARGET_ENV"` and evals stdout (`multi-agent-mux-delegate-job:37-45`). The command substitution captures stdout only, so the parser's stderr stays visible. Every printed line is `export NAME=<shlex.quote(value)>` for a key that passed `_KEY.fullmatch` and `is_reserved` (`envfile.py:74-83`, `112-113`). A value such as `BASH_VERSINFO[$(touch marker)0]` stays quoted text; the marker is not created (`test_a6`).
Failure:
- Missing interpreter: the substitution fails, the wrapper prints `ERROR: cannot load <path>` and exits 1 (`test_a1_an_unreadable_parser_fails_loudly`, `:138-146`).
- Unreadable or undecodable file: `envfile.py` prints `ERROR: {path}: cannot read (PermissionError|UnicodeDecodeError)` and returns 1 (`envfile.py:107-109`); the wrapper then prints `ERROR: cannot load <path> (see the line above)` and exits 1 (`:43-44`). The test requires both lines and requires the value to be absent from stderr.
- A missing file is still success with no exports (`envfile.py:103-104`, `test_a5` `:294-302`). The wrapper only enters this path when `[ -f "$TARGET_ENV" ]`.
Python entry points take the same read failure. `_python_load` asserts a non-zero exit, no `LOADED`, and `EnvFileError` in stderr.
## 3. Environment wins over the file
`apply` sets a key only when it is absent from the mapping (`envfile.py:92-94`). The shell side passes the process environment in, so a variable the wrapper already set, or one the caller exported, stays. Documented in `envfile.py:10`, the wrapper comment at `:37-40`, `.mam.env.example:24`, and `MESSAGING.md:366-369`.
Effect of leaving `source` (which overwrote the environment):
- The loop runner starts the wrapper with `dict(os.environ)` and sets `DELEGATE_JOB_PYTHON` to `sys.executable` when unset (`runner.py:548-550`), so the file cannot replace the runner's broker or interpreter.
- The remote runner already used the Python loader, which had the same precedence.
- Tests set `MQTT_BROKER` / `MQTT_PORT` / `MQTT_TLS` in the environment (`conftest.py:1568-1575`). The file can no longer point those back at another broker.
- A stale shell export of `MQTT_*` is no longer overwritten on each wrapper start. That is the intended precedence. `install.sh` still expands its seed before the file exists.
`test_a3` (`:230-238`) checks file `50` against environment `70` on a real dry-run record and on the Python loader.
## 4. Integer rule
`mam_int` (`lib.sh:2345-2358`) builds its class from the six ASCII blanks `$ ' \t\n\r\v\f '` and the digits `0123456789`, strips leading zeros, forces base 10, and accepts `min <= n <= 999999999`. `runner._positive_int` (`runner.py:71-81`) strips the same six characters and requires `isascii()` and `isdigit()`. The expectation helper uses the same ASCII strip (`test_issue26_ints.py:88-96`).
Minimums in `INT_KNOBS` (`:35-54`):
- Minimum 1 (0 would close or idle out immediately): `MAM_JOB_TIMEOUT_S` 3600, `MAM_HEARTBEAT_SEC` 30, `MAM_HEARTBEAT_MISSES` 4, `MAM_ACK_TIMEOUT_S` 180, and the five flags `--timeout`, `--idle-timeout`, `--heartbeat`, `--heartbeat-misses`, `--ack-timeout`.
- Minimum 0 (0 means check once or do not wait): `MAM_INJECT_ACK_GRACE_S`, `delegate_wait_started`'s `ack_s` (`lib.sh:2384`), `SKS_DIALOG_TIMEOUT` 30, `SKS_BUSY_TIMEOUT` 120, `SKS_DELIVERY_GRACE_S` 15, `SKS_QUIESCENT_TRIES` 20.
An invalid environment value warns and uses the default (`mam_int_knob`, `lib.sh:2364-2369`). An invalid flag exits 1. `parse_opts` records that the flag was present (`:167-179`) and refuses an empty value (`:199-203`); the default applies only when the flag is absent. The space-padded token keeps `HEARTBEAT_SEC` distinct from `HEARTBEAT_MISSES`. `test_b2` includes the explicit empty string among the refused values (`:228-232`).
Locale: `test_b1_unicode_blanks_and_digits_are_invalid_in_every_locale` (`:188-204`) runs U+2003, U+00A0, U+3000, and Arabic and fullwidth digits under `C` and `en_US.UTF-8`, through `mam_int`, `_positive_int`, the wrapper record, and the runner. All four reject them and keep the 3600 default.
Three runner settings stay on their own Python conversions and do not reach bash arithmetic: `MAM_WAIT_GRACE_S` (`runner.py:627-628`, `isdigit` then `int`), `MAM_CHILD_WAIT_S` (`:748-749`, `float`), `MAM_JOB_TREE_MAX` (`:752`, `int`). They predate this integer rule. `NOT_ARITHMETIC` classifies the shell defaults that use `[ -ge ]` / `[ -le ]` / `sleep` / an argv, and on this host's bash 3.2 those `[` comparisons are decimal.
## 5. Local MQTT broker
`tests/_local_mqtt.py` is an in-process MQTT 3.1.1 broker on `127.0.0.1:0`, daemon accept and connection threads (`:103-104`, `:139`), stopped by closing the listen socket and the live connections (`:107-120`). Scope is what the repo's clients use: protocol level 4, QoS 0/1, retained messages. QoS 2 closes the connection (`:190-191`).
A PUBLISH is routed and, when retained, stored before PUBACK (`:196-200`). The session fixture (`conftest.py:1568-1584`) sets `MQTT_BROKER` / `MQTT_PORT` / `MQTT_TLS` and drops `MQTT_HOST` and the credential and certificate variables for the session, then restores them. `test_c0` waits for CONNACK, SUBACK, UNSUBACK, and PUBACK before it reads broker state, and it checks topic, payload, QoS, and the retain flag (`test_local_mqtt.py:100-130`). `test_c0_holds_when_the_broker_is_slow` (`:140-160`) delays CONNECT, UNSUBSCRIBE, and routing so a test that reads state before the ack fails. `test_c2` (`:297-310`) rejects the public-broker literal outside an allowlist and rejects a helper that drops `MQTT_BROKER` / `MQTT_HOST` without naming a replacement. `test_c0_publish_event_reaches_job_subscriber_over_the_local_broker` uses a `uuid4` topic prefix.
Commit `2826e70` lists the moved cases: issue 18 acceptance 1/2/3/5, issue 6 `t1_1/2/7/8/14/15/16/20`, issue 19's two MQTT-loop cases, issue 33 `b3/r3/b7`, issue 30 `s9/s10/r2`, and calls that set 22. Those harnesses inject `local_broker_env()` and the "broker unreachable" skips are gone. `0eec795` does not touch the broker or those tests. This round's required run includes `tests/test_local_mqtt.py` and `tests/test_issue18_liveness_contract.py`; both are in the 174 passes below. Issue 6, 19, 30, and 33 were outside this brief's command.
## 6. The baseline-failure claim
`tests/test_issue10_13_purge.py` is identical to `e1eb22b` (`git diff` empty). On this host, with `HOME` and `--basetemp` under `/tmp` and with MQTT variables unset:
`23 passed in 11.62s`
The claim that a full-suite run shows 13 failures of which 12 are this host's baseline in that file does not reproduce here. The tests use `tmp_path` sandboxes and `uuid4` conversation ids. Nothing under the real home was purged.
## 7. Design boundary
The shell diff adds numeric parsing for clocks that already existed: `mam_int` / `mam_int_knob` on the job timeout, heartbeat, ack, inject grace, and the SKS dialog, busy, delivery, and quiescent counts (`lib.sh` around the `$(( $(date +%s) + $(mam_int_knob SKS_...) ))` deadlines). That is the decimal-integer rule, so `0700` is 700 and `0900` does not abort arithmetic.
No new bash judgment of a timeout label, a budget extension, or a stall close landed in this range. `MAM_STALL_WATCH` stays a string compare (`NOT_ARITHMETIC`). Closing remains the existing clocks. Nothing here implements #30 or #33, and nothing needs to move back to the planner.
## Should Python entry points raise?
Yes. A file that exists and cannot be read must stop the process. Warning and continuing would load an empty configuration and, when `MQTT_*` are unset, the public default broker. That is the fail-open `0eec795` removes, and it has to be the same in both loaders. `publish_event.main` calls `ensure_env_loaded()` after argument parsing and does not catch the exception (`publish_event.py:337`); the same call is the first environment step in `registry.main`, `job_subscriber.main`, `cli.main`, `bench.main`, and reconcile's embedded subscriber (`mqtt_common.py:113-115`). An agent whose `.mam.env` is unreadable then cannot publish `started` or `completed`, and the job closes on the clocks. That is the same outcome as refusing to run unconfigured.
The wrapper prints one `ERROR` line and exits 1. The Python process exits with a traceback that names `EnvFileError` and the path and the exception type, and does not include the file body. That difference is acceptable. `_env_loaded = True` is set before `_load_dotenv` (`mqtt_common.py:123-124`), so a caller that caught the exception would not retry in-process. The entry points above do not catch it.
## Checks
From the review snapshot, MQTT variables unset, no `.mam.env` exported, `AGENT_PYTHON_BIN` and the interpreter the pr-43 venv, `--basetemp` under `/tmp`:
`174 passed in 208.02s` — `tests/test_issue26_envfile.py`, `tests/test_issue26_ints.py`, `tests/test_local_mqtt.py`, `tests/test_issue18_liveness_contract.py`, `tests/test_tier1_unit.py`.
The full suite is the orchestrator's.
## Notes (non-blocking)
- Duplicate keys in one file are first-wins (`envfile.py:91-94`). The old `source` was last-wins. Both loaders agree. The example states environment-over-file and does not mention a repeated key inside the file.
- A `#` comment that also contains a NUL is skipped as a comment (`envfile.py:64-65` runs before the NUL check). A value line with a NUL still warns and is skipped.
- `MESSAGING.md:369` still says a missing explicit `MAM_ENV_FILE` logs an error and connections fail closed with `RuntimeError`. `mqtt_common.py:51-54` returns without loading when that path is not a regular file, and this PR does not change that branch. A file that exists and cannot be read now raises, which is the contract under review.
- `MAM_WAIT_GRACE_S`, `MAM_CHILD_WAIT_S`, and `MAM_JOB_TREE_MAX` remain outside `_positive_int` (section 4). They do not reach bash arithmetic.
[VERDICT: PASS]