Compare commits

..
9 Commits
Author SHA1 Message Date
GodopuandClaude Opus 5.5 75f364e4b4 fix(followups): F2 review R1 - a failed env load is not cached as loaded
ensure_env_loaded() set _env_loaded before _load_dotenv() ran. With F2,
load_env_or_report() turns the EnvFileError into a return value, so a second
main() in the same process skipped the unreadable file and ran unconfigured
(codex R1: registry.main twice -> rc 1 then rc 0 with a job registered; a
repaired file was never read). The flag is now set only after a successful
load: a failure is retried on the next call, a success still loads once.
envfile parses the whole file before applying, so a failed load sets nothing.

MESSAGING.md: the one-line ERROR is the Python entry points'; the wrapper
adds its own "ERROR: cannot load <path>" line (codex, minor).

Test: in one process, registry.main twice on an undecodable file is rc 1
both times, prints the ERROR line twice and registers nothing; the repaired
file is applied on the next call; a later change is not re-read. At 95eabc6
it fails with rc [1, 0], one job registered and the value unset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGpJXhSZasJLU38ksDghe
2026-10-02 09:29:11 +09:00
GodopuandClaude Opus 5.5 95eabc693f fix(followups): F2 - job_subscriber returns the env-file failure by name
5edf913 gave job_subscriber.main a bare `return 1`; its exit codes are
returned by name (test_issue5 test_job_subscriber_cli_contract_snapshot).
The value stays 1 - what the EnvFileError traceback exited with before -
and is RC_ERROR. The wrapper reads the same file first and exits 1 itself,
so it never starts a subscriber that would report this.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGpJXhSZasJLU38ksDghe
2026-10-02 08:39:22 +09:00
GodopuandClaude Opus 5.5 1e92ddc563 test(followups): F6 - the wrapper vanishes inside the second wait; the <= 60 s is the herdr wait's
delegate-job SKILL.md said a watcher outlives a killed wrapper by "one
wait, <= 60 s". That bound is the herdr wait's; a wrapper that goes while a
notice is being published is noticed when that publish ends. The text now
says both (PR #41 codex round 3).

"a wrapper gone during the wait" switched alive() by call count, so the
watcher saw the wrapper gone at the next loop head and never entered the
second wait (ticks == [1] with that band). The wrapper now vanishes inside
the second herdr wait (_during_notice's in_wait hook), and the test checks
that the second wait happened and that alive() was true until then. The
same test fails when the post-wait _wrapper_gone() check is removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGpJXhSZasJLU38ksDghe
2026-10-02 08:23:31 +09:00
GodopuandClaude Opus 5.5 c252ac4d54 fix(followups): F5 - no test switch in the stall watcher
stall_watch read MAM_TEST_UNRELIABLE_AGENT to pretend an adapter's
agent_status was unreliable (PR #41 M7). The branch is gone. S13 now runs
the real stall_watch.main() in a process where the adapter itself reports
agent_status_reliable = False (_UNRELIABLE_MAIN), and checks the same
things as before: no idle notice or nudge, the blocked notice, and the
"idle judgement off" log line. A static check keeps MAM_TEST_ out of the
watcher (fails at 0051fd6). Without the seam S13 fails on this tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGpJXhSZasJLU38ksDghe
2026-10-02 08:23:31 +09:00
GodopuandClaude Opus 5.5 fb1b93c665 fix(followups): F4b - bash integer knobs that reach [ -ge/-le/-gt ] or sleep go through mam_int_knob
A non-numeric MAM_READY_EMPTY_GIVEUP, SKS_EMPTY_GIVEUP, SKS_IDLE_RETRIES,
SKS_IDLE_RETRY_BACKOFF_S, MAM_LOCK_STEAL_RETRIES, MAM_DIFF_MAX_BYTES,
MAM_DIFF_MAX_LINES or RECONCILE_POLL_INTERVAL was a `[` error or a sleep
error at the point of use (PR #43 review claude R3). They now go through
mam_int_knob: decimal, a warning and the default for a bad value. Minimums
are in test_issue26_ints.py INT_KNOBS: 1 for the give-up counts (0 acts as
1), the diff caps (0 truncates every diff) and the poll interval (0 is a
busy loop); 0 for the retry counts (one attempt) and the retry backoff.

SKS_QUIESCENT_INTERVAL stays raw: its default is 0.5 s and sleep's
fraction is its meaning; NOT_ARITHMETIC says so.

loop_lock.sh and diff_collect.sh also run without lib.sh (the Python runner
and the tests source them alone), so mam_int / mam_int_knob move verbatim
to lib_int.sh, which lib.sh sources and those two source when the function
is missing. remove.sh's no-manifest fallback list names the new file
(install copies every file under .agents/skills). test_issue21_loop_b l15,
which builds a control root holding only diff_collect.sh, copies it too.

Tests: the table guard now reads every shell file; scripts sourced alone
read their knobs as decimal; _pane_quiescent with SKS_EMPTY_GIVEUP=abc
gives up with a warning and no `[` error (14 of the new cases fail at
0051fd6).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGpJXhSZasJLU38ksDghe
2026-10-02 08:23:31 +09:00
GodopuandClaude Opus 5.5 8b046390be fix(followups): F4a/F4c - the runner's wait knobs follow the integer rule; MAM_WAIT_GRACE_S documented
MAM_WAIT_GRACE_S silently fell back to 300 on anything but bare digits
(" 30 " too), MAM_CHILD_WAIT_S went through float() (abc raised ValueError,
0.5 and 1e3 were taken) and MAM_JOB_TREE_MAX through int() (abc and an
empty value raised). All three now go through runner._int_knob, which is
_positive_int's rule (PR #43 review grok): decimal, blanks around allowed,
an invalid value warns once and the default is used. Minimums:
MAM_WAIT_GRACE_S >= 0 (wait exactly the budget), MAM_CHILD_WAIT_S >= 0
(look once; test_issue34 uses 0), MAM_JOB_TREE_MAX >= 1 (a cap of 0 calls
every tree "over"; not waiting is MAM_CHILD_WAIT_S=0).

The loop SKILL.md now names MAM_WAIT_GRACE_S in the budget row and the
sub-job paragraph (PR #41 M6), with the knobs' integer rules.

Tests: RUNNER_INT_KNOBS in test_issue26_ints.py holds the minimums; the
real wait_for_descendants is driven per knob, and a static check ties the
call-site minimums to the table (15 of the new cases fail at 0051fd6; the
14 that pass are valid values whose result did not change).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGpJXhSZasJLU38ksDghe
2026-10-02 08:13:44 +09:00
GodopuandClaude Opus 5.5 71ea8095c5 fix(followups): F3 - .mam.env BOM, repeated keys and a missing MAM_ENV_FILE
PR #43 review (grok):
(a) A UTF-8 byte-order mark at the start of the file made the first line an
    "invalid key", so its setting was dropped. The one parser (envfile.py,
    used by the wrapper and mqtt_common) now reads with utf-8-sig.
(b) A key given twice keeps its first value (a later line does not replace
    it). The behaviour is unchanged; .mam.env.example's FORMAT section and
    envfile.py's docstring now say so.
(c) MESSAGING.md said a MAM_ENV_FILE naming no file logs an error and makes
    connections fail closed. Both loaders skip it silently and refuse the
    ambient search; a file that exists but cannot be read is the error
    (EnvFileError, one ERROR line and rc 1). The text now says that.

Tests A7: BOM in both loaders, first-value-wins in both loaders plus the
FORMAT text, and a missing explicit file loading neither it nor the ambient
.mam.env without an error plus the MESSAGING text (all three fail at 0051fd6).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGpJXhSZasJLU38ksDghe
2026-10-02 08:10:42 +09:00
GodopuandClaude Opus 5.5 5edf913d22 fix(followups): F2 - Python entry points report an unreadable env file in one line
A .mam.env that exists but cannot be read or decoded stopped the Python
entry points with an EnvFileError traceback, while the wrapper printed one
`ERROR:` line and exited 1 (PR #43 review claude S1). The mains that load the
env file - publish_event, registry, job_subscriber, cli, bench, heartbeat and
remote - now call mqtt_common.load_env_or_report(), which prints
`ERROR: <path>: cannot read (<exception type>)` and lets the main return 1.
No value or file content is printed. ensure_env_loaded() still raises: no
library function exits the process.

Test: every entry point, for a 000 file and an undecodable one, ends with
exactly that line and rc 1 and creates nothing (14 cases; all 14 fail at
0051fd6 with a traceback).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGpJXhSZasJLU38ksDghe
2026-10-02 08:09:46 +09:00
GodopuandClaude Opus 5.5 75936f11c6 test(followups): F1 - A8 writes the marker lstart as loop_lock.sh reads it
`ps -o lstart=` pads a one-digit day ("Oct  1"); loop_lock.sh's mam_lstart
squeezes the spaces before comparing, but A8 wrote the raw value, so the
live holder's marker looked stale and the second loop was not refused on
the 1st-9th of every month. The test now normalises like the product (the
product is unchanged) and also runs with a `ps` shim that always prints a
padded lstart, so the padded case is checked on every date.

The other tests that record a live lstart (test_o3_scoped_guard
_get_lstart) already normalise; test_o2's markers carry fixed stale values.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGpJXhSZasJLU38ksDghe
2026-10-02 08:08:26 +09:00
27 changed files with 437 additions and 89 deletions

No files matched your search

+8 -37
View File
@@ -2335,39 +2335,10 @@ sys.exit(1)
' 2>/dev/null
}
# mam_int <value> <min>
#
# Issue #26: the one integer rule for knobs that reach bash arithmetic (the
# Python twin is lib_py/loop/runner.py::_positive_int). Surrounding blanks are
# allowed, then digits only, read as DECIMAL (bash would read 0700 as octal
# 448 and fail on 0900), and min <= n <= 999999999. Prints n and returns 0, or
# returns 1. The per-knob minimum lives in tests/test_issue26_ints.py.
mam_int() {
# Spelled-out ASCII sets, not [[:space:]]/[0-9]: those follow the locale, and
# the runner's _positive_int takes ASCII only (review R2: U+2003 30 U+2003
# was 30 here under en_US.UTF-8 and invalid there).
local _ws=$' \t\n\r\v\f' _re
_re="^[${_ws}]*([0123456789]+)[${_ws}]*\$"
[[ "${1-}" =~ $_re ]] || return 1
local d="${BASH_REMATCH[1]}"
d="${d#"${d%%[!0]*}"}"
[ ${#d} -le 9 ] || return 1
local n=$(( 10#${d:-0} ))
[ "$n" -ge "$2" ] || return 1
printf '%s\n' "$n"
}
# mam_int_knob <ENV_NAME> <default> <min>
#
# The value of $ENV_NAME through mam_int; unset or empty gives <default>, an
# invalid value gives <default> with a warning on stderr.
mam_int_knob() {
local raw="${!1-}"
[ -n "$raw" ] || { printf '%s\n' "$2"; return 0; }
mam_int "$raw" "$3" && return 0
echo "WARNING: $1=$raw ignored (not a decimal integer >= $3); using $2" >&2
printf '%s\n' "$2"
}
# mam_int / mam_int_knob (Issue #26) live in lib_int.sh, shared with the
# scripts that run without lib.sh.
# shellcheck source=lib_int.sh
source "$(dirname "${BASH_SOURCE[0]}")/lib_int.sh"
# delegate_wait_started <job_id> [ack_timeout_s] [dispatch_token]
#
@@ -2443,7 +2414,7 @@ wait_for_tui_ready() {
fi
local empty_streak=0
local empty_giveup="${MAM_READY_EMPTY_GIVEUP:-5}"
local empty_giveup="$(mam_int_knob MAM_READY_EMPTY_GIVEUP 5 1)"
local modal_pat="$_MAM_MODAL_TOKENS"
[ -n "$modal_tokens" ] && modal_pat="${modal_pat}|${modal_tokens}"
@@ -2639,7 +2610,7 @@ _wait_session_gone() {
_pane_quiescent() {
local sess="$1" tries="${2:-20}" interval="${3:-0.5}" prev="__none__" cur i
local saw_output=0 empty_streak=0
local empty_giveup="${SKS_EMPTY_GIVEUP:-3}"
local empty_giveup="$(mam_int_knob SKS_EMPTY_GIVEUP 3 1)"
for ((i = 0; i < tries; i++)); do
cur=$(_pane_capture "$sess")
if [ -z "$cur" ]; then
@@ -2709,8 +2680,8 @@ except Exception:
return 0
fi
local max_retries="${SKS_IDLE_RETRIES:-2}"
local backoff_s="${SKS_IDLE_RETRY_BACKOFF_S:-10}"
local max_retries="$(mam_int_knob SKS_IDLE_RETRIES 2 0)"
local backoff_s="$(mam_int_knob SKS_IDLE_RETRY_BACKOFF_S 10 0)"
local try=0
while [ "$try" -le "$max_retries" ]; do
local watcher_out="" watcher_rc=0
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
# lib_int.sh - the integer rule for knobs (Issue #26), on its own so scripts
# that run without lib.sh (diff_collect.sh and loop_lock.sh, which the Python
# runner and the tests source alone) read their knobs the same way. lib.sh
# sources it. Functions only: sourcing it has no other effect.
# mam_int <value> <min>
#
# Issue #26: the one integer rule for knobs that reach bash arithmetic (the
# Python twin is lib_py/loop/runner.py::_positive_int). Surrounding blanks are
# allowed, then digits only, read as DECIMAL (bash would read 0700 as octal
# 448 and fail on 0900), and min <= n <= 999999999. Prints n and returns 0, or
# returns 1. The per-knob minimum lives in tests/test_issue26_ints.py.
mam_int() {
# Spelled-out ASCII sets, not [[:space:]]/[0-9]: those follow the locale, and
# the runner's _positive_int takes ASCII only (review R2: U+2003 30 U+2003
# was 30 here under en_US.UTF-8 and invalid there).
local _ws=$' \t\n\r\v\f' _re
_re="^[${_ws}]*([0123456789]+)[${_ws}]*\$"
[[ "${1-}" =~ $_re ]] || return 1
local d="${BASH_REMATCH[1]}"
d="${d#"${d%%[!0]*}"}"
[ ${#d} -le 9 ] || return 1
local n=$(( 10#${d:-0} ))
[ "$n" -ge "$2" ] || return 1
printf '%s\n' "$n"
}
# mam_int_knob <ENV_NAME> <default> <min>
#
# The value of $ENV_NAME through mam_int; unset or empty gives <default>, an
# invalid value gives <default> with a warning on stderr.
mam_int_knob() {
local raw="${!1-}"
[ -n "$raw" ] || { printf '%s\n' "$2"; return 0; }
mam_int "$raw" "$3" && return 0
echo "WARNING: $1=$raw ignored (not a decimal integer >= $3); using $2" >&2
printf '%s\n' "$2"
}
+2 -1
View File
@@ -163,7 +163,8 @@ def run_benchmark(n=50, transport="fake", connect_latency_ms=50.0,
def main(argv: Optional[List[str]] = None) -> int:
mqtt_common.ensure_env_loaded()
if not mqtt_common.load_env_or_report():
return 1
parser = argparse.ArgumentParser(prog="lib_py.delegation.bench")
parser.add_argument("--n", type=int, default=50)
parser.add_argument("--transport", default="fake", choices=["fake", "broker"])
+2 -1
View File
@@ -57,7 +57,8 @@ def _build_parser() -> argparse.ArgumentParser:
def main(argv: Optional[List[str]] = None) -> int:
mqtt_common.ensure_env_loaded()
if not mqtt_common.load_env_or_report():
return 1
args = _build_parser().parse_args(argv)
if args.command == "submit":
+5 -2
View File
@@ -7,7 +7,9 @@ must match ``^[A-Za-z_][A-Za-z0-9_]*$`` and not be one of bash's own variables
(RESERVED); a line that breaks either, or that has no `=`, is skipped with a
warning naming the line (never the value — the file holds secrets). A line
holding a NUL byte is skipped the same way (no environment can carry it).
Variables already in the environment win over the file. A file that exists
A UTF-8 byte-order mark at the start of the file is dropped. Variables
already in the environment win over the file, and a key given twice keeps its
first value. A file that exists
but cannot be read or decoded as a whole is an error (EnvFileError; the
``--shell`` command exits 1), not an empty configuration.
@@ -55,7 +57,8 @@ def parse(path: str) -> Tuple[List[Tuple[str, str]], List[str]]:
pairs: List[Tuple[str, str]] = []
warnings: List[str] = []
try:
with open(path, "r", encoding="utf-8") as f:
# utf-8-sig: a byte-order mark at the start is not part of the first key
with open(path, "r", encoding="utf-8-sig") as f:
lines = f.read().splitlines()
except (OSError, UnicodeDecodeError) as e:
raise EnvFileError(f"{path}: cannot read ({type(e).__name__})") from None
@@ -493,7 +493,8 @@ def main(argv=None) -> int:
help="stop once this process (the agent) is gone")
parser.add_argument("-v", "--verbose", action="store_true")
args = parser.parse_args(argv)
mqtt_common.ensure_env_loaded()
if not mqtt_common.load_env_or_report():
return 1
mqtt_common.setup_logging(logging.DEBUG if args.verbose else logging.INFO)
return run(args.job, args.registry_dir, args.interval, args.expect_dispatch_token,
args.attempt_id, args.max_lifetime, args.watch_pid)
@@ -587,7 +587,10 @@ def main(argv=None) -> int:
parser.add_argument("-v", "--verbose", action="store_true")
args = parser.parse_args(argv)
mqtt_common.ensure_env_loaded()
if not mqtt_common.load_env_or_report():
# rc 1, as the EnvFileError traceback exited before. The wrapper reads
# the same file first and exits 1 itself, so it never sees this one.
return RC_ERROR
mqtt_common.setup_logging(logging.DEBUG if args.verbose else logging.WARNING)
try:
@@ -21,6 +21,7 @@ import hmac
import json
import logging
import os
import sys
import tempfile
import time
import uuid
@@ -110,9 +111,10 @@ def ensure_env_loaded(workspace_dir: Optional[str] = None) -> None:
"""Load the workspace env file exactly once per process (D6, issue #5 phase 1).
This used to run at import time. Importing a library must not mutate
``os.environ``, so every *process entry point* calls this instead:
``os.environ``, so every *process entry point* calls this instead -
``publish_event.main``, ``registry.main``, ``job_subscriber.main``,
``cli.main``, ``bench.main``, and reconcile.sh's embedded subscriber.
``cli.main``, ``bench.main``, ``heartbeat.main`` and ``remote.main``
through :func:`load_env_or_report`, and reconcile.sh's embedded subscriber.
Production semantics are unchanged because every production path passes
through one of those, and OS env still wins over the file
(see :func:`_parse_env_file`).
@@ -120,8 +122,24 @@ def ensure_env_loaded(workspace_dir: Optional[str] = None) -> None:
global _env_loaded
if _env_loaded:
return
_env_loaded = True
_load_dotenv(workspace_dir)
# Only a load that succeeded is cached: after an EnvFileError the next
# call reads the file again (load_env_or_report returns, it does not exit).
_env_loaded = True
def load_env_or_report() -> bool:
"""ensure_env_loaded() for a process entry point: an env file that exists
but cannot be read ends the process the way the wrapper does - one
`ERROR: <path>: cannot read (<exception type>)` line, no traceback, no
value - and the caller returns 1. False after printing; library code keeps
calling ensure_env_loaded() and never exits."""
try:
ensure_env_loaded()
except envfile.EnvFileError as e:
print(f"ERROR: {e}", file=sys.stderr)
return False
return True
# --------------------------------------------------------------------------
# Constants
@@ -334,7 +334,8 @@ def main(argv=None, _beat: bool = False) -> int:
logger.error("--heartbeat needs a positive period and a non-terminal event")
return 1
mqtt_common.ensure_env_loaded()
if not mqtt_common.load_env_or_report():
return 1
mqtt_common.setup_logging(logging.DEBUG if args.verbose else logging.WARNING)
# --- parse optional data JSON (parameter error -> exit 1) ---
+2 -1
View File
@@ -533,7 +533,8 @@ def _prompt_from_args(parser: argparse.ArgumentParser, args: argparse.Namespace)
def main(argv: Optional[List[str]] = None) -> int:
mqtt_common.ensure_env_loaded()
if not mqtt_common.load_env_or_report():
return 1
mqtt_common.setup_logging(logging.INFO)
parser = _build_parser()
args = parser.parse_args(argv)
+2 -1
View File
@@ -460,7 +460,8 @@ def main(argv=None) -> int:
p_r.add_argument("--python", default=None)
parser.add_argument("-v", "--verbose", action="store_true")
args = parser.parse_args(argv)
mqtt_common.ensure_env_loaded()
if not mqtt_common.load_env_or_report():
return 1
mqtt_common.setup_logging(logging.DEBUG if args.verbose else logging.INFO)
if args.command == "dispatch":
try:
+22 -6
View File
@@ -624,8 +624,22 @@ class LoopRunner:
today's 3900 - 3600). The grace covers the wrapper's close/publish tail
after the wall clock and the 1 s record poll; it is not proportional
to the budget because nothing observed would justify that."""
grace = os.environ.get("MAM_WAIT_GRACE_S", "")
return self.job_budget + (int(grace) if grace.isdigit() else 300)
grace = self._int_knob("MAM_WAIT_GRACE_S", 0)
return self.job_budget + (300 if grace is None else grace)
def _int_knob(self, name: str, minimum: int) -> Optional[int]:
"""$name by _positive_int's rule (the minimums are in
tests/test_issue26_ints.py RUNNER_INT_KNOBS); None when unset, or when
invalid - then with one warning per knob - so the caller uses its default."""
raw = os.environ.get(name, "")
n = _positive_int(raw, minimum)
if n is None and raw:
warned = self.__dict__.setdefault("_warned_knobs", set())
if name not in warned:
warned.add(name)
print(f"WARNING: {name}={raw} ignored (not a decimal integer >= {minimum}); using the default",
file=sys.stderr)
return n
def wait_for_job(self, job_id: str, max_wait: Optional[float] = None, check_interval: float = 1.0) -> bool:
"""Blocking wait for job completion or error with safety timeout."""
@@ -745,11 +759,13 @@ class LoopRunner:
A terminal record is not a stopped agent (a deadline close does not
interrupt it); that is left to the sub-job table and the stale-review
guard, since nothing signals such an agent's end."""
child_env = os.environ.get("MAM_CHILD_WAIT_S")
max_wait = float(child_env) if child_env else float(self.job_wait_s)
wait_src = "MAM_CHILD_WAIT_S" if child_env else \
child_wait = self._int_knob("MAM_CHILD_WAIT_S", 0)
max_wait = float(child_wait if child_wait is not None else self.job_wait_s)
wait_src = "MAM_CHILD_WAIT_S" if child_wait is not None else \
f"MAM_CHILD_WAIT_S default: job budget {self.job_budget} + {self.job_wait_s - self.job_budget}"
tree_max = int(os.environ.get("MAM_JOB_TREE_MAX", "50"))
tree_max = self._int_knob("MAM_JOB_TREE_MAX", 1)
if tree_max is None:
tree_max = 50
since = self._created_at(root)
start = time.time()
last = None
-2
View File
@@ -225,8 +225,6 @@ class StallWatch(object):
name = resolve_agent(args.agent, args.target)
adapter = get_adapter(name) if name else None
reliable = bool(adapter.agent_status_reliable) if adapter else True
if os.environ.get("MAM_TEST_UNRELIABLE_AGENT") == args.agent:
reliable = False
self.st = WatchState(idle_s=float(os.environ.get("MAM_STALL_IDLE_S", "600")), reliable=reliable)
self.fail_s = float(os.environ.get("MAM_STALL_HERDR_FAIL_S", "120"))
self.once = set()
@@ -50,8 +50,9 @@ multi-agent-mux-delegate-job submit \
# MAM_STALL_IDLE_S (600) / MAM_STALL_HERDR_FAIL_S (120) / MAM_STALL_WATCH=0: the stall watcher (Issue #30).
# The watcher lives as long as the job's wrapper: the wrapper stops it (KILL, at once)
# when its subscriber ends, and the watcher exits on its own once the wrapper pid is gone
# (checked after every herdr wait, so it can outlive a killed wrapper by one wait, <= 60 s,
# and never sends a notice or a nudge after it saw the wrapper gone).
# (checked after every herdr wait, so it can outlive a killed wrapper by one herdr wait,
# <= 60 s - or, when the wrapper goes while a notice is being published, until that
# publish ends - and never sends a notice or a nudge after it saw the wrapper gone).
# --parent-job <id> (Issue #34): this job is delegated from job <id>, which
# must exist in the registry (else exit 1). A loop waiting on <id> waits for
# it too and shows it to reviewers. Default: $MAM_JOB_ID (the remote runner
+2 -2
View File
@@ -206,7 +206,7 @@ sequenceDiagram
| **Phase 3: Consensus** | `--all-reviewer` | 레지스트리에 등록된 모든 active 리뷰어 세션을 자동으로 수집하여 리뷰를 돌립니다. (`--reviewer` 옵션과는 상호 배타적이며, 지정/수집된 모든 리뷰어의 PASS 만장일치가 항상 필요합니다.) |
| **Iterative Loop** | `--max-loop M` | NOT PASS 판정 시 최대 `M`회까지 Creator가 자체 수정합니다. `--plan` 모드에서 리뷰어가 리포트에 `[ESCALATE: PLANNER]` 태그를 남기면 설계 변경 수준으로 판단하여 Planner에게 계획 갱신을 위임합니다 (린트는 리뷰어가 검토 관점 중 하나로 확인할 뿐, 별도의 자동 게이트는 아닙니다). |
| **Rebuttal** | `--max-rebut N` | 이터레이션당 Creator 반론 횟수 (기본 1, `0`이면 끔). |
| **Job budget** | `--job-timeout <sec>` | 위임 잡마다의 예산 (기본 `MAM_JOB_TIMEOUT_S` 또는 3600). 잡 대기 상한은 예산 + 300 s. |
| **Job budget** | `--job-timeout <sec>` | 위임 잡마다의 예산 (기본 `MAM_JOB_TIMEOUT_S` 또는 3600). 잡 대기 상한은 예산 + `MAM_WAIT_GRACE_S`(기본 300 s, 0 이상의 10진 정수; 잘못된 값은 경고 후 300) — 벽시계 뒤 래퍼의 종결·발행 꼬리와 1 s 레코드 폴링을 덮는 여유입니다. |
| **Control** | `--verbose` / `--cleanup` | 상세 로그 / 성공 시 임시 job 디렉터리 삭제. |
| **Source root** | `--worktree <path>` (env `MAM_LOOP_WORKTREE`, CLI 우선) | diff 수집·base·head·빈 변경분 판정만 이 git 워크트리 기준으로 합니다. wrapper·레지스트리·체크포인트·loop guard·리포트 승격·cleanup 은 루프를 띄운 본 체크아웃(제어 루트)에 남습니다. 모든 위임 프롬프트 앞에 `[워크트리 컨텍스트]` 블록(경로·브랜치·base·head·커밋 수·미커밋 변경)이 붙습니다. Python 루프 전용(Issue #21). |
| **Diff base** | `--base <ref>` (CLI 전용, env 없음) | 리뷰 diff 의 base 를 고정합니다. 소스 HEAD 의 조상이어야 하며 아니면 exit 1. 생략 시 Phase 2 시작 시점의 소스 HEAD 입니다. |
@@ -219,7 +219,7 @@ sequenceDiagram
**리뷰 identity (Issue #21 항목 5).** `--worktree`/`--base` 가 있으면 매 리뷰 라운드를 `(loop_id, base, head, tree)` 에 묶습니다. 소스의 미커밋·untracked 상태까지 담은 스냅샷(ref 없는 커밋)을 만들고, 리뷰어가 받는 diff 도 그 스냅샷에서 만듭니다(`base..snapshot`). 라운드가 끝난 뒤 소스가 바뀌었으면 그 라운드의 PASS 는 버리고 수정 잡 없이 다시 리뷰합니다(마지막 반복이면 exit 1). 승격 직전에도 한 번 더 확인하며, 승격된 리포트 옆에 `report-<jid>.identity.json`(loop_id·base·head·tree·스냅샷 커밋·리뷰어·`transport`·`evidence`: `review-tree`/`source-tree`/`diff-only`)을 둡니다. 추적되지 않는 중첩 git 저장소는 스냅샷에 담을 수 없어 거부합니다. 서브모듈 안의 미커밋·untracked 내용도 스냅샷에는 gitlink 커밋만 남으므로 거부합니다(라운드 시작 시 exit 1, 리뷰 중에 생기면 그 라운드는 stale). `submodule.*.ignore` 설정으로 가려지지 않습니다. 리뷰어가 없으면 creator 셀프 리뷰가 같은 리뷰어 경로(스냅샷 diff·증거 검사·승격 직전 재확인·사이드카)를 거치고, NOT PASS 면 수정 잡 없이 다시 리뷰합니다. 리뷰 잡의 `[워크트리 컨텍스트]` 는 검증 대상을 `[리뷰 identity]` 의 검증 경로 하나로만 지시합니다.
**하위 위임 잡 (Issue #34).** creator 가 작업 일부를 `delegate-job submit --parent-job <자기 job_id>` 로 맡기면, 루프는 creator 잡(실행·수정·순응)이 끝난 뒤 그 잡의 자손(자식·손자…, 폴링마다 다시 읽음)의 레코드가 모두 종결될 때까지 기다린 다음 리뷰로 넘어갑니다. 깊이 제한은 없고 상한은 둘입니다: `MAM_CHILD_WAIT_S`(기본 잡 예산 + 300 s, creator 잡 종결부터; 각 하위 잡은 자기 등록 + `timeout_sec`(#33, 기본 `MAM_JOB_TIMEOUT_S`/3600 s)에 닫히므로 보통 그 전에 끝납니다)와 `MAM_JOB_TREE_MAX`(기본 50 개, 넘으면 기다리지 않음). 그만큼 루프 벽시계가 늘어납니다. 루프를 잡으로 감싸 돌리면 바깥 잡의 3600 s 가 먼저 끝날 수 있습니다. 자식의 결과는 루프를 멈추지 않고 리뷰 근거가 됩니다: 하위 잡이나 귀속되지 않은 열린 잡이 있으면 모든 리뷰 프롬프트(셀프 리뷰 포함) 앞에 `[하위 위임 잡]` 표(상태·사유·깊이)와 `[귀속되지 않은 열린 잡]`(부모 미표기로 이 작업 중 등록된 열린 잡) 줄이 붙고(둘 다 없으면 프롬프트는 전과 같습니다), 리뷰어는 `OPEN`·`CLOSED-UNCONFIRMED`·실패(`error`/`cancelled`/`NOT-DISPATCHED`) 하위 잡이 있으면 creator 리포트가 대체 완료를 입증하지 않는 한 NOT PASS 를 냅니다. 표는 이 실행의 모든 creator 잡(앞 회차의 실행·수정·순응 잡 포함)의 자손을 매 회차 다시 읽어 만듭니다 — 수정 잡이 새로 생겼다고 앞 잡의 열린·실패 하위 잡이 사라지지 않습니다(대기는 새 creator 잡의 자손만 하므로 대기 상한이 회차마다 다시 쌓이지 않습니다). `CLOSED-UNCONFIRMED` 는 오케스트레이터가 마감(`deadline_timeout`/`wall_clock_timeout`/`agent_blocked`)으로 닫았거나 사유가 없는 `error` 입니다 — 레코드만 닫혔고 에이전트는 인터럽트되지 않으므로 계속 쓰고 있을 수 있습니다. creator 잡 자체의 실패는 전처럼 exit 1 입니다.
**하위 위임 잡 (Issue #34).** creator 가 작업 일부를 `delegate-job submit --parent-job <자기 job_id>` 로 맡기면, 루프는 creator 잡(실행·수정·순응)이 끝난 뒤 그 잡의 자손(자식·손자…, 폴링마다 다시 읽음)의 레코드가 모두 종결될 때까지 기다린 다음 리뷰로 넘어갑니다. 깊이 제한은 없고 상한은 둘입니다: `MAM_CHILD_WAIT_S`(기본 잡 예산 + `MAM_WAIT_GRACE_S`, creator 잡 종결부터; 0 이면 한 번만 확인; 각 하위 잡은 자기 등록 + `timeout_sec`(#33, 기본 `MAM_JOB_TIMEOUT_S`/3600 s)에 닫히므로 보통 그 전에 끝납니다)와 `MAM_JOB_TREE_MAX`(기본 50 개, 1 이상, 넘으면 기다리지 않음). 둘 다 10진 정수이고 잘못된 값은 경고 후 기본값을 씁니다. 그만큼 루프 벽시계가 늘어납니다. 루프를 잡으로 감싸 돌리면 바깥 잡의 3600 s 가 먼저 끝날 수 있습니다. 자식의 결과는 루프를 멈추지 않고 리뷰 근거가 됩니다: 하위 잡이나 귀속되지 않은 열린 잡이 있으면 모든 리뷰 프롬프트(셀프 리뷰 포함) 앞에 `[하위 위임 잡]` 표(상태·사유·깊이)와 `[귀속되지 않은 열린 잡]`(부모 미표기로 이 작업 중 등록된 열린 잡) 줄이 붙고(둘 다 없으면 프롬프트는 전과 같습니다), 리뷰어는 `OPEN`·`CLOSED-UNCONFIRMED`·실패(`error`/`cancelled`/`NOT-DISPATCHED`) 하위 잡이 있으면 creator 리포트가 대체 완료를 입증하지 않는 한 NOT PASS 를 냅니다. 표는 이 실행의 모든 creator 잡(앞 회차의 실행·수정·순응 잡 포함)의 자손을 매 회차 다시 읽어 만듭니다 — 수정 잡이 새로 생겼다고 앞 잡의 열린·실패 하위 잡이 사라지지 않습니다(대기는 새 creator 잡의 자손만 하므로 대기 상한이 회차마다 다시 쌓이지 않습니다). `CLOSED-UNCONFIRMED` 는 오케스트레이터가 마감(`deadline_timeout`/`wall_clock_timeout`/`agent_blocked`)으로 닫았거나 사유가 없는 `error` 입니다 — 레코드만 닫혔고 에이전트는 인터럽트되지 않으므로 계속 쓰고 있을 수 있습니다. creator 잡 자체의 실패는 전처럼 exit 1 입니다.
**리뷰 중 소스 변경 = 승인 무효 (하위 잡이 있을 때, Issue #34).** `--worktree`/`--base` 가 없어도 이 실행에 하위 잡(또는 귀속되지 않은 열린 잡)이 있는 라운드마다 리뷰 요청 직전 소스(= 제어 루트)의 head 와 미커밋·untracked 를 포함한 tree 를 기록하고, 판정 수집 뒤와 승격 직전에 다시 계산합니다. 바뀌었으면 위 identity 모드와 같이 그 라운드의 PASS 를 버리고 수정 잡 없이 다시 리뷰합니다(마지막 반복이면 exit 1). 제어 평면(`.mam`, 잡·감사 로그 디렉터리)은 비교에서 뺍니다. 리뷰어가 ignore 되지 않는 산출물을 소스에 남기면 라운드가 stale 이 되니 산출물은 소스 밖에 둡니다. 하위 잡이 없는 루프는 이 검사를 하지 않습니다(전과 같음) — 소스에 잡마다 바뀌는 untracked 파일이 있을 수 있기 때문입니다. git 저장소가 아니거나 서브모듈이 dirty 해 지문을 만들 수 없으면 경고(`stale-review guard is off`) 후 기존 동작입니다. 루프가 끝난 **뒤**의 늦은 쓰기는 막을 수 없으며 다음 루프의 diff 에 미커밋 변경으로 드러납니다.
@@ -9,8 +9,11 @@ MAM_DIFF_NESTED_MARKER="!!! NOT EXPANDED IN THIS DIFF !!!"
# These caps bound the evidence a reviewer is shown, nothing else. The prompt
# reaches the agent by file (Issue #36), so delivery has no size cap of its own:
# only a diff over these limits is truncated (and marked).
MAM_DIFF_MAX_BYTES="${MAM_DIFF_MAX_BYTES:-200000}"
MAM_DIFF_MAX_LINES="${MAM_DIFF_MAX_LINES:-4000}"
# The runner sources this file without lib.sh; the integer rule is lib_int.sh's.
# shellcheck source=../../lib_int.sh
declare -F mam_int_knob >/dev/null || source "$(dirname "${BASH_SOURCE[0]}")/../../lib_int.sh"
MAM_DIFF_MAX_BYTES="$(mam_int_knob MAM_DIFF_MAX_BYTES 200000 1)"
MAM_DIFF_MAX_LINES="$(mam_int_knob MAM_DIFF_MAX_LINES 4000 1)"
_mam_nested_dir_diff() {
local d="$1" outer_top="$2" inner_top=""
@@ -2,7 +2,10 @@
# loop_lock.sh — Atomic race-free loop lock acquisition and marker ownership (O-2)
# Manages .mam/loop-guard-active with atomic hard links and pid+lstart identity validation.
MAM_LOCK_STEAL_RETRIES="${MAM_LOCK_STEAL_RETRIES:-3}"
# This file is also sourced without lib.sh (tests); the integer rule is lib_int.sh's.
# shellcheck source=../../lib_int.sh
declare -F mam_int_knob >/dev/null || source "$(dirname "${BASH_SOURCE[0]}")/../../lib_int.sh"
MAM_LOCK_STEAL_RETRIES="$(mam_int_knob MAM_LOCK_STEAL_RETRIES 3 0)"
mam_lstart() {
local pid="${1:-$$}"
@@ -38,7 +38,7 @@ SUBSCRIBE=0
# (raised from 600s to align with job timeout defaults); idle 0 = never idle-out.
SUB_TIMEOUT=0
SUB_IDLE_TIMEOUT=3600
POLL_INTERVAL="${RECONCILE_POLL_INTERVAL:-15}"
POLL_INTERVAL="$(mam_int_knob RECONCILE_POLL_INTERVAL 15 1)"
while [ $# -gt 0 ]; do
case "$1" in
+2
View File
@@ -21,7 +21,9 @@
# - `export KEY=...`, a key that is not [A-Za-z_][A-Za-z0-9_]*, a line
# without `=`, a line with a NUL byte, and bash's own variables (IFS, PS4,
# RANDOM, BASH_*, ...) are skipped with a warning naming the line number;
# - a UTF-8 byte-order mark at the start of the file is ignored;
# - a variable already set in the environment wins over this file;
# - a key given twice keeps its FIRST value (a later line does not replace it);
# - a file that exists but cannot be read is an error (the wrapper exits 1).
#
# SECURITY: never put real secrets in this template. Secret-bearing vars use a
+1 -1
View File
@@ -366,7 +366,7 @@ The subscriber acts as the central execution watchdog:
* **`.mam.env` Resolution Hierarchy (`_load_dotenv`)**:
Configuration files are resolved with strict precedence rules:
1. **OS Environment Precedence**: Any variable already defined in `os.environ` is preserved and never overwritten by file-based configs.
2. **Explicit Override (`MAM_ENV_FILE`)**: If `MAM_ENV_FILE` is set, only that specific file is parsed. If the specified file does not exist, an error is logged and ambient search is refused (preventing silent fallback to unintended parent configs). Connection attempts fail-closed (`RuntimeError`).
2. **Explicit Override (`MAM_ENV_FILE`)**: If `MAM_ENV_FILE` is set, only that specific file is parsed. If the specified file does not exist, it is skipped silently and ambient search is refused (no fallback to unintended parent configs): the process runs on its environment and the defaults. If it exists but cannot be read or decoded, that is an error (`EnvFileError`): the Python entry points print one `ERROR: <path>: cannot read (<exception type>)` line and exit 1; the wrapper prints that line followed by `ERROR: cannot load <path> (see the line above)` and exits 1.
3. **Workspace Root Auto-Discovery**: If `MAM_ENV_FILE` is not set, the resolver searches candidate paths in order: `MAM_REAL_ROOT`, `WORKSPACE_ROOT`, upward directory walk searching for `.agents` or `.git` boundary markers, and `os.getcwd()`.
4. **Public Broker Security Alert (B-17)**: If the final resolved host falls back to the public sandbox `broker.hivemq.com`, a prominent security warning is emitted.
* **Broker Config Resolution (`broker_config_from_job`)**:
+1
View File
@@ -82,6 +82,7 @@ if [ -f "$MANIFEST_FILE" ]; then
else
fallback_assets=(
".agents/skills/lib.sh"
".agents/skills/lib_int.sh"
".agents/skills/lib_py"
".agents/skills/multi-agent-mux-create"
".agents/skills/multi-agent-mux-delegate-job"
+34 -5
View File
@@ -16,6 +16,8 @@ import subprocess
import time
from pathlib import Path
import pytest
from test_issue21_loop_b import LOCAL_ROWS, _drive, _reviews, _sidecars, _state
from test_issue21_phase2_loop_a import (REM, _calls, _prepare, _resume, _rows, _run, _skill, _wt, # noqa: F401
ctl)
@@ -253,19 +255,46 @@ def test_a7_reviewer_test_output_stays_out_of_the_source_and_the_sandbox(wt_repo
# A8 -------------------------------------------------------------------------
def test_a8_second_loop_is_refused_before_a_scheduler_exists(ctl):
# `ps -o lstart=` pads a one-digit day with a second space ("Oct 1"); this
# stands in for it so the padded case runs on every date, not only on the 1st-9th.
_PADDED_PS = """#!/bin/sh
case " $* " in
*" lstart= "*) out=$(/bin/ps "$@") || exit $?; [ -n "$out" ] && echo "Thu Oct 1 08:02:03 2026"; exit 0 ;;
esac
exec /bin/ps "$@"
"""
def _marker_lstart(pid: int, env: dict) -> str:
"""The lstart a marker records, normalised as loop_lock.sh's mam_lstart does
(`tr -s ' '`, ends trimmed) - the raw value keeps the padding and never matches."""
out = subprocess.run(["ps", "-p", str(pid), "-o", "lstart="], capture_output=True, text=True, env=env).stdout
return " ".join(out.split())
@pytest.mark.parametrize("ps", ["real", "padded"])
def test_a8_second_loop_is_refused_before_a_scheduler_exists(ctl, tmp_path, ps):
main = ctl
wt2 = _wt(main, "wt2")
_g(main, "worktree", "add", "-q", "--detach", str(wt2))
env = {**os.environ, "WORKSPACE_ROOT": str(main), "MAM_LOOP_NO_FREEZE": "1"}
env.pop("MAM_LOOP_PY", None)
if ps == "padded":
shim = tmp_path / "ps-shim"
shim.mkdir()
(shim / "ps").write_text(_PADDED_PS)
(shim / "ps").chmod(0o755)
env["PATH"] = f"{shim}{os.pathsep}{env['PATH']}"
holder = subprocess.Popen(["sleep", "60"])
try:
lstart = subprocess.run(["ps", "-p", str(holder.pid), "-o", "lstart="], capture_output=True,
text=True).stdout.strip()
lstart = _marker_lstart(holder.pid, env)
assert lstart, "ps found no holder"
if ps == "padded":
assert lstart == "Thu Oct 1 08:02:03 2026", f"the shim was not used: {lstart!r}"
marker = main.parent / f"{main.name}-loop-marker"
content = f"pid={holder.pid}\nlstart={lstart}\nstarted={time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())}\n"
marker.write_text(content)
env = {**os.environ, "WORKSPACE_ROOT": str(main), "MAM_LOOP_MARKER": str(marker), "MAM_LOOP_NO_FREEZE": "1"}
env.pop("MAM_LOOP_PY", None)
env["MAM_LOOP_MARKER"] = str(marker)
res = subprocess.run(["bash", _skill(main, "multi-agent-mux-loop", "scripts", "run_loop.sh"),
"--creator", "c", "--task", "t", "--worktree", str(wt2), "--base", "HEAD",
"--review-only", "--reviewer", "r"],
+1
View File
@@ -393,6 +393,7 @@ def test_l15_truncated_diff_refuses_diff_only_but_not_local(wt_repo, monkeypatch
dst = main / ".agents" / "skills" / "multi-agent-mux-loop" / "scripts"
dst.mkdir(parents=True)
(dst / "diff_collect.sh").write_text(DIFF_SH.read_text())
(dst.parent.parent / "lib_int.sh").write_text((SKILLS / "lib_int.sh").read_text()) # its integer rule
monkeypatch.setenv("MAM_DIFF_MAX_LINES", "1")
_state(monkeypatch, LOCAL_ROWS + [REMOTE_ROW])
argv = ["--worktree", str(wt), "--base", before, "--review-only", "--remote-review", "diff-only"]
+122
View File
@@ -180,6 +180,47 @@ def test_a1_a_file_that_cannot_be_read_stops_both_loaders(tmp_path, kind):
f.chmod(0o600)
# Follow-up F2 (PR #43 claude S1): every main that loads the env file, with
# arguments its parser accepts when it parses before loading.
ENTRY_POINTS = {
"publish_event": ["--job", "abcd1234", "--event", "progress", "--registry-dir", "reg"],
"registry": [],
"job_subscriber": ["--job", "abcd1234", "--registry-dir", "reg"],
"cli": [],
"bench": [],
"heartbeat": ["--job", "abcd1234", "--interval", "30", "--registry-dir", "reg"],
"remote": ["run", "--session", "s", "--registry-dir", "reg"],
}
@pytest.mark.parametrize("kind", ["permission", "utf8"])
@pytest.mark.parametrize("module", sorted(ENTRY_POINTS))
def test_a1_python_entry_points_report_an_unreadable_file_in_one_line(tmp_path, module, kind):
"""The wrapper's shape at every Python entry point: one `ERROR:` line
naming the path and the exception type, rc 1, no traceback, no value."""
f = tmp_path / "broken.env"
if kind == "permission":
if os.geteuid() == 0:
pytest.skip("root reads a 000 file")
f.write_text("PROBE_A=one\n")
f.chmod(0)
cause = "PermissionError"
else:
f.write_bytes(b"PROBE_A=one\nPROBE_B=\xff\xfe\n")
cause = "UnicodeDecodeError"
try:
# main() itself: `-m` adds runpy's warning (the package __init__ imports these modules)
code = f"import sys; from lib_py.delegation import {module} as m; sys.exit(m.main(sys.argv[1:]))"
res = subprocess.run([sys.executable, "-c", code, *ENTRY_POINTS[module]],
capture_output=True, text=True, env=_env(tmp_path, f), cwd=str(tmp_path), timeout=60)
finally:
f.chmod(0o600)
assert res.returncode == 1, (res.returncode, res.stderr[-500:])
assert res.stderr.strip().splitlines() == [f"ERROR: {f}: cannot read ({cause})"], res.stderr[-500:]
assert "one" not in res.stdout + res.stderr
assert not (tmp_path / "reg").exists(), "nothing ran past the env file"
def test_a1_a_nul_line_is_skipped_by_both_loaders(tmp_path):
"""Review R4: no environment can carry a NUL byte. Both loaders skip that
line with a warning naming it (not its value) and keep the lines around it."""
@@ -364,3 +405,84 @@ def test_a6_the_file_cannot_replace_the_wrappers_own_variables(tmp_path):
assert not (tmp_path / "pwned-lib").exists()
got = _wrapper_values(_env(tmp_path, f), ["SCRIPT_DIR", "TARGET_ENV"], tmp_path)
assert _split(got.stdout) == {"SCRIPT_DIR": str(WRAPPER.parent), "TARGET_ENV": str(f)}
# ---------------------------------------------------------------------------
# A7 - follow-ups F3 (PR #43 review grok)
# ---------------------------------------------------------------------------
def test_a7_a_leading_bom_is_not_part_of_the_first_key(tmp_path):
"""An editor's byte-order mark used to turn the first line into
"invalid key" and drop the first setting, in both loaders."""
f = tmp_path / "bom.env"
f.write_bytes(b"\xef\xbb\xbfFIRST=one\nSECOND=two\n")
env = _env(tmp_path, f)
res = _wrapper_values(env, ["FIRST", "SECOND"], tmp_path)
assert res.returncode == 0, res.stderr
assert _split(res.stdout) == {"FIRST": "one", "SECOND": "two"}
assert "skipped" not in res.stderr, res.stderr
assert _python_values(env, ["FIRST", "SECOND"]) == {"FIRST": "one", "SECOND": "two"}
assert envfile.parse(str(f)) == ([("FIRST", "one"), ("SECOND", "two")], [])
def test_a7_a_repeated_key_keeps_its_first_value_as_documented(tmp_path):
f = tmp_path / "dup.env"
f.write_text("DUP=first\nDUP=second\n")
env = _env(tmp_path, f)
res = _wrapper_values(env, ["DUP"], tmp_path)
assert res.returncode == 0, res.stderr
assert _split(res.stdout) == {"DUP": "first"} == _python_values(env, ["DUP"])
fmt = (REPO_ROOT / ".mam.env.example").read_text().split("# SECURITY:")[0]
assert "a key given twice keeps its FIRST value" in fmt, "document it in the FORMAT section"
def test_a7_a_missing_explicit_file_loads_nothing_and_says_nothing(tmp_path):
"""MAM_ENV_FILE naming no file: no error, and no fallback to the file the
ambient search would find - in both loaders, and so in MESSAGING.md."""
(tmp_path / ".mam.env").write_text("PROBE_AMBIENT=found\n")
absent = tmp_path / "absent.env"
env = _env(tmp_path, absent)
res = _wrapper_values(env, ["PROBE_AMBIENT"], tmp_path)
assert res.returncode == 0, res.stderr
assert _split(res.stdout) == {"PROBE_AMBIENT": "<unset>"}
assert "ERROR" not in res.stderr and str(absent) not in res.stderr, res.stderr
assert _python_values(env, ["PROBE_AMBIENT"]) == {"PROBE_AMBIENT": "<unset>"}
doc = next(l for l in (REPO_ROOT / "MESSAGING.md").read_text().splitlines() if "Explicit Override (`MAM_ENV_FILE`)" in l)
assert "does not exist, it is skipped silently" in doc and "an error is logged" not in doc, doc
_RETRY_PROBE = r"""
import json, os, sys
from pathlib import Path
from lib_py.delegation import mqtt_common, registry
env_file, jobs = Path(sys.argv[1]), sys.argv[2]
argv = ["--registry-dir", jobs, "register", "--prompt", "retry probe"]
out = {"rc": [registry.main(argv), registry.main(argv)]}
out["registered"] = sorted(p.name for p in Path(jobs).glob("*.json")) if Path(jobs).exists() else []
env_file.write_text("PROBE_CONFIG=from-file\n")
out["recovered"] = [mqtt_common.load_env_or_report(), os.environ.get("PROBE_CONFIG")]
env_file.write_text("PROBE_CONFIG=changed\n")
out["cached"] = [mqtt_common.load_env_or_report(), os.environ.get("PROBE_CONFIG")]
print(json.dumps(out))
"""
def test_a1_a_failed_load_is_not_cached_as_loaded(tmp_path):
"""Review R1 (codex): load_env_or_report returns instead of exiting, so the
failure must not mark the env as loaded - in one process, a second main()
on the same unreadable file is rc 1 again and registers nothing, a repaired
file is read on the next call, and only a success is loaded once."""
f = tmp_path / "broken.env"
f.write_bytes(b"PROBE_CONFIG=from-file\nBAD=\xff\n")
jobs = tmp_path / "jobs"
env = _env(tmp_path, f)
env.pop("PROBE_CONFIG", None)
res = subprocess.run([sys.executable, "-c", _RETRY_PROBE, str(f), str(jobs)], capture_output=True, text=True,
env=env, cwd=str(tmp_path), timeout=60)
assert res.returncode == 0, res.stderr[-800:]
got = json.loads(res.stdout.strip().splitlines()[-1])
assert got["rc"] == [1, 1], (got, res.stderr[-800:])
assert got["registered"] == [], got
assert res.stderr.count(f"ERROR: {f}: cannot read (UnicodeDecodeError)") == 2, res.stderr[-800:]
assert got["recovered"] == [True, "from-file"], got
assert got["cached"] == [True, "from-file"], "a successful load stays once per process"
+113 -6
View File
@@ -43,6 +43,18 @@ INT_KNOBS = {
"SKS_DELIVERY_GRACE_S": (0, "15"),
# _pane_quiescent's `for ((i = 0; i < tries; i++))` (review R2); 0 keeps "headless" (rc 2)
"SKS_QUIESCENT_TRIES": (0, "20"),
# Follow-up F4b: `[ -ge ]`/`[ -le ]`/`[ -gt ]` operands and integer sleeps, where
# "abc" was a `[` error or a sleep error. lib.sh, reconcile.sh (which sources
# it), and loop_lock.sh / diff_collect.sh, which run without lib.sh and
# source lib_int.sh themselves.
"MAM_READY_EMPTY_GIVEUP": (1, "5"), # consecutive empty captures; 0 would act as 1
"SKS_EMPTY_GIVEUP": (1, "3"), # the same, in _pane_quiescent
"SKS_IDLE_RETRIES": (0, "2"), # 0: one wait, no retry
"SKS_IDLE_RETRY_BACKOFF_S": (0, "10"), # sleep between retries; test_issue6 uses 0
"MAM_LOCK_STEAL_RETRIES": (0, "3"), # 0: one attempt, no steal retry
"MAM_DIFF_MAX_BYTES": (1, "200000"), # 0 would truncate every diff
"MAM_DIFF_MAX_LINES": (1, "4000"),
"RECONCILE_POLL_INTERVAL": (1, "15"), # sleep; 0 would poll in a busy loop
# delegate_wait_started's ack_s (MAM_ONBOARD_ACK_S / DELEGATE_JOB_ACK_S via create_session.sh)
"delegate_wait_started:ack_s": (0, "180"),
# wrapper flags
@@ -53,6 +65,15 @@ INT_KNOBS = {
"--ack-timeout": (1, None),
}
# The loop runner's own integer knobs (follow-up F4a): read by runner._int_knob
# (_positive_int's rule) with these minimums; an invalid value warns once and
# the default is used - never an exception. knob -> (minimum, default)
RUNNER_INT_KNOBS = {
"MAM_WAIT_GRACE_S": (0, 300), # 0: wait exactly the job budget
"MAM_CHILD_WAIT_S": (0, None), # 0: look at the sub-jobs once; default: budget + grace
"MAM_JOB_TREE_MAX": (1, 50), # 0 would refuse every tree as "over"; not waiting is MAM_CHILD_WAIT_S=0
}
# The last two are longer than Python's int() digit limit (4300): the rule must
# bound the significant digits before converting (review R3).
INPUTS = ["30", "0700", "0900", " 30 ", "0", "-5", "abc", "", "1e3", "9999999999", "000000000030",
@@ -76,11 +97,8 @@ NOT_ARITHMETIC = {
"MAM_MAX_PANE_COLS": "argv of python -m lib_py.layout", "MAM_MAX_PANE_ROWS": "argv of python -m lib_py.layout",
"MAM_BUFFER_GC_MINUTES": "find -mmin", "MAM_DIALOG_TAIL_LINES": "tail -n",
"SKS_IDLE_TIMEOUT_MS": "argv of the python idle watcher", "MAM_DIALOG_RESEND_S": "python int()",
"MAM_READY_EMPTY_GIVEUP": "[ -ge ] (decimal)", "SKS_EMPTY_GIVEUP": "[ -ge ] (decimal)",
"SKS_IDLE_RETRIES": "[ -le ] (decimal)", "SKS_IDLE_RETRY_BACKOFF_S": "sleep",
"SKS_QUIESCENT_INTERVAL": "sleep", "MAM_LOCK_STEAL_RETRIES": "[ -le ] (decimal)",
"MAM_DIFF_MAX_BYTES": "[ -gt ] (decimal)", "MAM_DIFF_MAX_LINES": "[ -gt ] (decimal)",
"RECONCILE_POLL_INTERVAL": "sleep",
# sleep's fractional seconds are its meaning here (default 0.5): mam_int would refuse them
"SKS_QUIESCENT_INTERVAL": "sleep, fractional by default (0.5) - left raw",
"DELEGATE_JOB_ACK_S": "create_session.sh -> delegate_wait_started, which runs mam_int on it",
}
@@ -132,6 +150,21 @@ def _runner(home: Path, extra_env=None, *argv) -> subprocess.CompletedProcess:
env=_base_env(home, extra_env), cwd=str(home), timeout=60)
def _runner_wait(home: Path, extra_env=None, kids: int = 0) -> tuple:
"""The real runner waiting on a sub-job tree of `kids` open jobs (budget 1 s):
({grace, ok}, everything it printed)."""
code = ("import json, sys; from lib_py.loop import runner as lr; "
"r = lr.LoopRunner(['--creator', 'c', '--task', 't', '--job-timeout', '1']); r.parse_args(); "
"lr.job_registry.descendants_of = lambda root, d: [{'job_id': 'k%d' % i, 'status': 'running'} "
"for i in range(int(sys.argv[1]))]; "
"ok = r.wait_for_descendants('root', interval=0.1); "
"print(json.dumps({'grace': r.job_wait_s - r.job_budget, 'ok': ok}))")
res = subprocess.run([sys.executable, "-c", code, str(kids)], capture_output=True, text=True,
env=_base_env(home, {k: v for k, v in (extra_env or {}).items()}), cwd=str(home), timeout=60)
assert res.returncode == 0 and "Traceback" not in res.stderr, res.stderr[-800:]
return json.loads(res.stdout.strip().splitlines()[-1]), res.stdout + res.stderr
def _bash(snippet: str, home: Path, extra_env=None) -> subprocess.CompletedProcess:
return subprocess.run(["bash", "-c", f'source "{LIB_SH}" >/dev/null 2>&1; {snippet}'], capture_output=True,
text=True, env=_base_env(home, extra_env), cwd=str(home), timeout=60)
@@ -214,6 +247,50 @@ def test_b1_sks_knobs_keep_zero_and_read_decimal(tmp_path, knob):
assert ("ignored" in res.stderr) is warns, (value, res.stderr)
@pytest.mark.parametrize("value", INPUTS, ids=_id)
def test_b1_runner_wait_grace_is_decimal_or_the_default(tmp_path, value):
minimum, default = RUNNER_INT_KNOBS["MAM_WAIT_GRACE_S"]
want = _expected(value, minimum)
got, out = _runner_wait(tmp_path, {"MAM_WAIT_GRACE_S": value})
assert got["grace"] == (default if want is None else want), out[-500:]
assert ("WARNING: MAM_WAIT_GRACE_S=" in out and "ignored" in out) is (want is None and value != ""), out[-500:]
@pytest.mark.parametrize("value,waited,warns", [("0", "0s (MAM_CHILD_WAIT_S)", False),
(" 0 ", "0s (MAM_CHILD_WAIT_S)", False),
("01", "1s (MAM_CHILD_WAIT_S)", False),
("abc", "1s (MAM_CHILD_WAIT_S default", True),
("-5", "1s (MAM_CHILD_WAIT_S default", True),
("0.5", "1s (MAM_CHILD_WAIT_S default", True),
("9999999999", "1s (MAM_CHILD_WAIT_S default", True),
("", "1s (MAM_CHILD_WAIT_S default", False)])
def test_b1_runner_child_wait_is_decimal_or_the_default(tmp_path, value, waited, warns):
"""MAM_CHILD_WAIT_S=abc raised ValueError; the default is budget + grace (1 + 0 here)."""
got, out = _runner_wait(tmp_path, {"MAM_CHILD_WAIT_S": value, "MAM_WAIT_GRACE_S": "0"}, kids=1)
assert got["ok"] is False and f"ended after {waited}" in out, out[-500:]
assert ("WARNING: MAM_CHILD_WAIT_S=" in out and "ignored" in out) is warns, out[-500:]
@pytest.mark.parametrize("value,over,warns", [("2", True, False), ("03", False, False), (" 2 ", True, False),
("0", False, True), ("abc", False, True), ("-1", False, True),
("", False, False)])
def test_b1_runner_job_tree_max_is_decimal_or_the_default(tmp_path, value, over, warns):
"""Three open sub-jobs against the cap; an invalid cap is 50 with a warning."""
got, out = _runner_wait(tmp_path, {"MAM_JOB_TREE_MAX": value, "MAM_CHILD_WAIT_S": "0"}, kids=3)
assert got["ok"] is False
assert ("over MAM_JOB_TREE_MAX=" in out) is over, out[-500:]
assert ("ended after 0s" in out) is (not over), out[-500:]
assert ("WARNING: MAM_JOB_TREE_MAX=" in out and "ignored" in out) is warns, out[-500:]
def test_b5_runner_knobs_use_the_table_minimum():
src = (SKILLS / "lib_py" / "loop" / "runner.py").read_text()
calls = dict(re.findall(r'_int_knob\("([A-Z_]+)", (\d+)\)', src))
assert {k: int(v) for k, v in calls.items()} == {k: m for k, (m, _) in RUNNER_INT_KNOBS.items()}
for name in RUNNER_INT_KNOBS:
assert f'os.environ.get("{name}"' not in src, f"{name} is read raw"
# ---------------------------------------------------------------------------
# B2 - flags
# ---------------------------------------------------------------------------
@@ -339,7 +416,7 @@ def test_b5_no_env_default_inside_arithmetic(path):
def test_b5_every_normalised_knob_uses_the_table_minimum():
src = WRAPPER.read_text() + "\n" + LIB_SH.read_text()
src = "\n".join(f.read_text() for f in [WRAPPER, *sorted(SKILLS.glob("**/*.sh"))])
calls = re.findall(r"mam_int_knob ([A-Z_]+) (\S+) (\d+)\)", src)
seen = {}
for name, default, minimum in calls:
@@ -377,6 +454,36 @@ def test_b5_every_integer_env_default_is_classified():
assert not stale, f"no longer in the code: {stale}"
LOOP_LOCK = SKILLS / "multi-agent-mux-loop" / "scripts" / "loop_lock.sh"
DIFF_COLLECT = SKILLS / "multi-agent-mux-loop" / "scripts" / "diff_collect.sh"
@pytest.mark.parametrize("value", ["abc", "0900", " 7 ", "-1", "1.5", "0", ""], ids=_id)
@pytest.mark.parametrize("script,knobs", [(LOOP_LOCK, ["MAM_LOCK_STEAL_RETRIES"]),
(DIFF_COLLECT, ["MAM_DIFF_MAX_BYTES", "MAM_DIFF_MAX_LINES"])],
ids=lambda v: v.name if isinstance(v, Path) else "")
def test_b5_scripts_sourced_without_lib_sh_read_their_knobs_as_decimal(tmp_path, script, knobs, value):
"""F4b: these files are sourced alone (the runner, the tests): they bring
the integer rule with them, and "abc" no longer reaches `[ -le ]`/`[ -gt ]`."""
probe = "; ".join(f'printf "%s=%s\\n" {k} "${k}"' for k in knobs)
res = subprocess.run(["bash", "-c", f'source "{script}"; {probe}'], capture_output=True, text=True,
env=_base_env(tmp_path, {k: value for k in knobs}), cwd=str(tmp_path), timeout=60)
assert res.returncode == 0, res.stderr
for k in knobs:
minimum, default = INT_KNOBS[k]
want = _expected(value, minimum)
assert f"{k}={default if want is None else want}" in res.stdout.splitlines(), (res.stdout, res.stderr)
assert (f"WARNING: {k}=" in res.stderr) is (want is None and value != ""), res.stderr
def test_b5_pane_quiescent_gives_up_on_empty_captures_with_a_bad_knob(tmp_path):
"""F4b: SKS_EMPTY_GIVEUP=abc was a `[` error on every empty capture."""
res = _bash('_pane_capture() { :; }; _pane_quiescent s 20 0; echo "rc=$?"', tmp_path, {"SKS_EMPTY_GIVEUP": "abc"})
assert "rc=2" in res.stdout, (res.stdout, res.stderr)
assert "integer expression expected" not in res.stderr, res.stderr
assert "WARNING: SKS_EMPTY_GIVEUP=abc ignored" in res.stderr, res.stderr
@pytest.mark.parametrize("value,rc", [("900", 0), ("0900", 0), ("0", 2)])
def test_b5_pane_quiescent_reads_the_tries_knob_as_decimal(tmp_path, value, rc):
"""The consuming function with the call site's normalisation (review R2):
+20 -6
View File
@@ -193,11 +193,20 @@ def _kill(p: subprocess.Popen) -> None:
p.wait()
def _watch(env: dict, reg: Path, jid: str, target: str, agent: str) -> subprocess.Popen:
# The real watcher's main() in a process whose `agent` adapter reports an
# agent_status that cannot drive the idle judgement (S13) - the production
# code has no switch for it.
_UNRELIABLE_MAIN = ("import sys; from lib_py.agents.registry import get_adapter; "
"type(get_adapter(sys.argv[1])).agent_status_reliable = property(lambda self: False); "
"import lib_py.stall_watch as sw; sys.exit(sw.main(sys.argv[2:]))")
def _watch(env: dict, reg: Path, jid: str, target: str, agent: str, unreliable: bool = False) -> subprocess.Popen:
tok = _record(reg, jid)["dispatch_token"]
return subprocess.Popen([sys.executable, "-m", "lib_py.stall_watch", "--job", jid,
"--registry-dir", str(reg), "--target", target, "--agent", agent,
"--expect-dispatch-token", tok],
argv = ["--job", jid, "--registry-dir", str(reg), "--target", target, "--agent", agent,
"--expect-dispatch-token", tok]
entry = ["-c", _UNRELIABLE_MAIN, agent] if unreliable else ["-m", "lib_py.stall_watch"]
return subprocess.Popen([sys.executable, *entry, *argv],
env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
@@ -444,13 +453,12 @@ def test_s12_the_watcher_can_only_publish_progress():
def test_s13_unreliable_agent_status_turns_idle_off_but_keeps_blocked(box):
_, state, reg, env = box
env["MAM_TEST_UNRELIABLE_AGENT"] = "claude"
jid = _job(env, reg)
agent = _proc("exec sleep 300")
try:
_agent(state, "creator-claude-t", "claude", agent.pid, "idle", _pane("claude"),
detection="Do you want to proceed?")
w = _watch(env, reg, jid, "creator-claude-t", "claude")
w = _watch(env, reg, jid, "creator-claude-t", "claude", unreliable=True)
time.sleep(5)
assert _details(reg, jid) == [] and not _prompts(state)
st = json.loads(state.read_text())
@@ -464,6 +472,12 @@ def test_s13_unreliable_agent_status_turns_idle_off_but_keeps_blocked(box):
_kill(agent)
def test_s13_the_unreliable_case_needs_no_switch_in_the_product():
"""PR #41 M7: the test sets the adapter up (_UNRELIABLE_MAIN); the watcher
reads no environment variable to pretend an adapter is unreliable."""
assert "MAM_TEST_" not in STALL_WATCH.read_text()
# ---------------------------------------------------------------------------
# scope - the #34 rule reaches the python herdr calls
# ---------------------------------------------------------------------------
+17 -6
View File
@@ -329,9 +329,10 @@ def test_a_refused_idle_notice_withholds_the_nudge(tmp_path, monkeypatch):
# round-2 review (codex D1/D2) - the last gate sits between the notice and the keys
# ---------------------------------------------------------------------------
def _during_notice(tmp_path, monkeypatch, change, alive=lambda pid: True):
def _during_notice(tmp_path, monkeypatch, change, alive=lambda pid: True, in_wait=lambda tick: None):
"""Idle past S on the second tick; `change(rec)` runs inside the notice's
publish, as another actor would act while it waits on the wire."""
publish, as another actor would act while it waits on the wire, and
`in_wait(tick)` inside each herdr wait."""
from lib_py import stall_watch
import argparse
import uuid
@@ -343,6 +344,7 @@ def _during_notice(tmp_path, monkeypatch, change, alive=lambda pid: True):
def fake_wait_once(target, until=(), timeout_ms=None, session=None, run=None):
from lib_py.loop import watcher as w
ticks.append(1)
in_wait(len(ticks))
if len(ticks) == 1:
return w.WaitOutcome(w.MATCHED, agent={"agent_status": "idle", "state_change_seq": 1, "pane_id": "w1:p1"})
if len(ticks) > 2: # a watcher that did not stop: end the run through the record
@@ -416,10 +418,19 @@ def test_an_unreadable_record_at_the_last_gate_withholds_the_nudge(tmp_path, mon
def test_a_wrapper_gone_during_the_wait_gets_no_notice_and_no_nudge(tmp_path, monkeypatch):
"""Idle past S and the wrapper vanished during the same wait: the watcher
leaves before judging - no notice, no keys, the record untouched."""
calls = []
gone, seen = [], []
def alive(pid):
calls.append(pid)
return len(calls) <= 2 # alive at the first loop head and after the first wait, gone after
rc, prompts, notes = _during_notice(tmp_path, monkeypatch, lambda rec: None, alive=alive)
seen.append((len(ticks), not gone))
return not gone
ticks = []
def in_wait(tick):
ticks.append(tick)
if tick == 2:
gone.append(True) # inside the second wait, while the watcher is blocked in herdr
rc, prompts, notes = _during_notice(tmp_path, monkeypatch, lambda rec: None, alive=alive, in_wait=in_wait)
assert rc == 0 and notes == [] and prompts == [], (prompts, notes)
assert ticks == [1, 2], "the wrapper must vanish inside the second wait, not before it"
assert seen[-1] == (2, False) and all(ok for _, ok in seen[:-1]), seen