Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75f364e4b4 | ||
|
|
95eabc693f | ||
|
|
1e92ddc563 | ||
|
|
c252ac4d54 | ||
|
|
fb1b93c665 | ||
|
|
8b046390be | ||
|
|
71ea8095c5 | ||
|
|
5edf913d22 | ||
|
|
75936f11c6 |
No files matched your search
+8
-37
@@ -2335,39 +2335,10 @@ sys.exit(1)
|
|||||||
' 2>/dev/null
|
' 2>/dev/null
|
||||||
}
|
}
|
||||||
|
|
||||||
# mam_int <value> <min>
|
# mam_int / mam_int_knob (Issue #26) live in lib_int.sh, shared with the
|
||||||
#
|
# scripts that run without lib.sh.
|
||||||
# Issue #26: the one integer rule for knobs that reach bash arithmetic (the
|
# shellcheck source=lib_int.sh
|
||||||
# Python twin is lib_py/loop/runner.py::_positive_int). Surrounding blanks are
|
source "$(dirname "${BASH_SOURCE[0]}")/lib_int.sh"
|
||||||
# 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"
|
|
||||||
}
|
|
||||||
|
|
||||||
# delegate_wait_started <job_id> [ack_timeout_s] [dispatch_token]
|
# delegate_wait_started <job_id> [ack_timeout_s] [dispatch_token]
|
||||||
#
|
#
|
||||||
@@ -2443,7 +2414,7 @@ wait_for_tui_ready() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
local empty_streak=0
|
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"
|
local modal_pat="$_MAM_MODAL_TOKENS"
|
||||||
[ -n "$modal_tokens" ] && modal_pat="${modal_pat}|${modal_tokens}"
|
[ -n "$modal_tokens" ] && modal_pat="${modal_pat}|${modal_tokens}"
|
||||||
|
|
||||||
@@ -2639,7 +2610,7 @@ _wait_session_gone() {
|
|||||||
_pane_quiescent() {
|
_pane_quiescent() {
|
||||||
local sess="$1" tries="${2:-20}" interval="${3:-0.5}" prev="__none__" cur i
|
local sess="$1" tries="${2:-20}" interval="${3:-0.5}" prev="__none__" cur i
|
||||||
local saw_output=0 empty_streak=0
|
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
|
for ((i = 0; i < tries; i++)); do
|
||||||
cur=$(_pane_capture "$sess")
|
cur=$(_pane_capture "$sess")
|
||||||
if [ -z "$cur" ]; then
|
if [ -z "$cur" ]; then
|
||||||
@@ -2709,8 +2680,8 @@ except Exception:
|
|||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
local max_retries="${SKS_IDLE_RETRIES:-2}"
|
local max_retries="$(mam_int_knob SKS_IDLE_RETRIES 2 0)"
|
||||||
local backoff_s="${SKS_IDLE_RETRY_BACKOFF_S:-10}"
|
local backoff_s="$(mam_int_knob SKS_IDLE_RETRY_BACKOFF_S 10 0)"
|
||||||
local try=0
|
local try=0
|
||||||
while [ "$try" -le "$max_retries" ]; do
|
while [ "$try" -le "$max_retries" ]; do
|
||||||
local watcher_out="" watcher_rc=0
|
local watcher_out="" watcher_rc=0
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -163,7 +163,8 @@ def run_benchmark(n=50, transport="fake", connect_latency_ms=50.0,
|
|||||||
|
|
||||||
|
|
||||||
def main(argv: Optional[List[str]] = None) -> int:
|
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 = argparse.ArgumentParser(prog="lib_py.delegation.bench")
|
||||||
parser.add_argument("--n", type=int, default=50)
|
parser.add_argument("--n", type=int, default=50)
|
||||||
parser.add_argument("--transport", default="fake", choices=["fake", "broker"])
|
parser.add_argument("--transport", default="fake", choices=["fake", "broker"])
|
||||||
|
|||||||
@@ -57,7 +57,8 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||||||
|
|
||||||
|
|
||||||
def main(argv: Optional[List[str]] = None) -> int:
|
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)
|
args = _build_parser().parse_args(argv)
|
||||||
|
|
||||||
if args.command == "submit":
|
if args.command == "submit":
|
||||||
|
|||||||
@@ -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
|
(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
|
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).
|
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
|
but cannot be read or decoded as a whole is an error (EnvFileError; the
|
||||||
``--shell`` command exits 1), not an empty configuration.
|
``--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]] = []
|
pairs: List[Tuple[str, str]] = []
|
||||||
warnings: List[str] = []
|
warnings: List[str] = []
|
||||||
try:
|
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()
|
lines = f.read().splitlines()
|
||||||
except (OSError, UnicodeDecodeError) as e:
|
except (OSError, UnicodeDecodeError) as e:
|
||||||
raise EnvFileError(f"{path}: cannot read ({type(e).__name__})") from None
|
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")
|
help="stop once this process (the agent) is gone")
|
||||||
parser.add_argument("-v", "--verbose", action="store_true")
|
parser.add_argument("-v", "--verbose", action="store_true")
|
||||||
args = parser.parse_args(argv)
|
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)
|
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,
|
return run(args.job, args.registry_dir, args.interval, args.expect_dispatch_token,
|
||||||
args.attempt_id, args.max_lifetime, args.watch_pid)
|
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")
|
parser.add_argument("-v", "--verbose", action="store_true")
|
||||||
args = parser.parse_args(argv)
|
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)
|
mqtt_common.setup_logging(logging.DEBUG if args.verbose else logging.WARNING)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import hmac
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
import uuid
|
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).
|
"""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
|
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``,
|
``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
|
Production semantics are unchanged because every production path passes
|
||||||
through one of those, and OS env still wins over the file
|
through one of those, and OS env still wins over the file
|
||||||
(see :func:`_parse_env_file`).
|
(see :func:`_parse_env_file`).
|
||||||
@@ -120,8 +122,24 @@ def ensure_env_loaded(workspace_dir: Optional[str] = None) -> None:
|
|||||||
global _env_loaded
|
global _env_loaded
|
||||||
if _env_loaded:
|
if _env_loaded:
|
||||||
return
|
return
|
||||||
_env_loaded = True
|
|
||||||
_load_dotenv(workspace_dir)
|
_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
|
# 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")
|
logger.error("--heartbeat needs a positive period and a non-terminal event")
|
||||||
return 1
|
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)
|
mqtt_common.setup_logging(logging.DEBUG if args.verbose else logging.WARNING)
|
||||||
|
|
||||||
# --- parse optional data JSON (parameter error -> exit 1) ---
|
# --- parse optional data JSON (parameter error -> exit 1) ---
|
||||||
|
|||||||
@@ -533,7 +533,8 @@ def _prompt_from_args(parser: argparse.ArgumentParser, args: argparse.Namespace)
|
|||||||
|
|
||||||
|
|
||||||
def main(argv: Optional[List[str]] = None) -> int:
|
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)
|
mqtt_common.setup_logging(logging.INFO)
|
||||||
parser = _build_parser()
|
parser = _build_parser()
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
|
|||||||
@@ -460,7 +460,8 @@ def main(argv=None) -> int:
|
|||||||
p_r.add_argument("--python", default=None)
|
p_r.add_argument("--python", default=None)
|
||||||
parser.add_argument("-v", "--verbose", action="store_true")
|
parser.add_argument("-v", "--verbose", action="store_true")
|
||||||
args = parser.parse_args(argv)
|
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)
|
mqtt_common.setup_logging(logging.DEBUG if args.verbose else logging.INFO)
|
||||||
if args.command == "dispatch":
|
if args.command == "dispatch":
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -624,8 +624,22 @@ class LoopRunner:
|
|||||||
today's 3900 - 3600). The grace covers the wrapper's close/publish tail
|
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
|
after the wall clock and the 1 s record poll; it is not proportional
|
||||||
to the budget because nothing observed would justify that."""
|
to the budget because nothing observed would justify that."""
|
||||||
grace = os.environ.get("MAM_WAIT_GRACE_S", "")
|
grace = self._int_knob("MAM_WAIT_GRACE_S", 0)
|
||||||
return self.job_budget + (int(grace) if grace.isdigit() else 300)
|
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:
|
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."""
|
"""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
|
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
|
interrupt it); that is left to the sub-job table and the stale-review
|
||||||
guard, since nothing signals such an agent's end."""
|
guard, since nothing signals such an agent's end."""
|
||||||
child_env = os.environ.get("MAM_CHILD_WAIT_S")
|
child_wait = self._int_knob("MAM_CHILD_WAIT_S", 0)
|
||||||
max_wait = float(child_env) if child_env else float(self.job_wait_s)
|
max_wait = float(child_wait if child_wait is not None else self.job_wait_s)
|
||||||
wait_src = "MAM_CHILD_WAIT_S" if child_env else \
|
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}"
|
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)
|
since = self._created_at(root)
|
||||||
start = time.time()
|
start = time.time()
|
||||||
last = None
|
last = None
|
||||||
|
|||||||
@@ -225,8 +225,6 @@ class StallWatch(object):
|
|||||||
name = resolve_agent(args.agent, args.target)
|
name = resolve_agent(args.agent, args.target)
|
||||||
adapter = get_adapter(name) if name else None
|
adapter = get_adapter(name) if name else None
|
||||||
reliable = bool(adapter.agent_status_reliable) if adapter else True
|
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.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.fail_s = float(os.environ.get("MAM_STALL_HERDR_FAIL_S", "120"))
|
||||||
self.once = set()
|
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).
|
# 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)
|
# 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
|
# 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,
|
# (checked after every herdr wait, so it can outlive a killed wrapper by one herdr wait,
|
||||||
# and never sends a notice or a nudge after it saw the wrapper gone).
|
# <= 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
|
# --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
|
# 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
|
# it too and shows it to reviewers. Default: $MAM_JOB_ID (the remote runner
|
||||||
|
|||||||
@@ -206,7 +206,7 @@ sequenceDiagram
|
|||||||
| **Phase 3: Consensus** | `--all-reviewer` | 레지스트리에 등록된 모든 active 리뷰어 세션을 자동으로 수집하여 리뷰를 돌립니다. (`--reviewer` 옵션과는 상호 배타적이며, 지정/수집된 모든 리뷰어의 PASS 만장일치가 항상 필요합니다.) |
|
| **Phase 3: Consensus** | `--all-reviewer` | 레지스트리에 등록된 모든 active 리뷰어 세션을 자동으로 수집하여 리뷰를 돌립니다. (`--reviewer` 옵션과는 상호 배타적이며, 지정/수집된 모든 리뷰어의 PASS 만장일치가 항상 필요합니다.) |
|
||||||
| **Iterative Loop** | `--max-loop M` | NOT PASS 판정 시 최대 `M`회까지 Creator가 자체 수정합니다. `--plan` 모드에서 리뷰어가 리포트에 `[ESCALATE: PLANNER]` 태그를 남기면 설계 변경 수준으로 판단하여 Planner에게 계획 갱신을 위임합니다 (린트는 리뷰어가 검토 관점 중 하나로 확인할 뿐, 별도의 자동 게이트는 아닙니다). |
|
| **Iterative Loop** | `--max-loop M` | NOT PASS 판정 시 최대 `M`회까지 Creator가 자체 수정합니다. `--plan` 모드에서 리뷰어가 리포트에 `[ESCALATE: PLANNER]` 태그를 남기면 설계 변경 수준으로 판단하여 Planner에게 계획 갱신을 위임합니다 (린트는 리뷰어가 검토 관점 중 하나로 확인할 뿐, 별도의 자동 게이트는 아닙니다). |
|
||||||
| **Rebuttal** | `--max-rebut N` | 이터레이션당 Creator 반론 횟수 (기본 1, `0`이면 끔). |
|
| **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 디렉터리 삭제. |
|
| **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). |
|
| **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 입니다. |
|
| **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]` 의 검증 경로 하나로만 지시합니다.
|
**리뷰 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 에 미커밋 변경으로 드러납니다.
|
**리뷰 중 소스 변경 = 승인 무효 (하위 잡이 있을 때, 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
|
# 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:
|
# 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).
|
# only a diff over these limits is truncated (and marked).
|
||||||
MAM_DIFF_MAX_BYTES="${MAM_DIFF_MAX_BYTES:-200000}"
|
# The runner sources this file without lib.sh; the integer rule is lib_int.sh's.
|
||||||
MAM_DIFF_MAX_LINES="${MAM_DIFF_MAX_LINES:-4000}"
|
# 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() {
|
_mam_nested_dir_diff() {
|
||||||
local d="$1" outer_top="$2" inner_top=""
|
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)
|
# 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.
|
# 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() {
|
mam_lstart() {
|
||||||
local pid="${1:-$$}"
|
local pid="${1:-$$}"
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ SUBSCRIBE=0
|
|||||||
# (raised from 600s to align with job timeout defaults); idle 0 = never idle-out.
|
# (raised from 600s to align with job timeout defaults); idle 0 = never idle-out.
|
||||||
SUB_TIMEOUT=0
|
SUB_TIMEOUT=0
|
||||||
SUB_IDLE_TIMEOUT=3600
|
SUB_IDLE_TIMEOUT=3600
|
||||||
POLL_INTERVAL="${RECONCILE_POLL_INTERVAL:-15}"
|
POLL_INTERVAL="$(mam_int_knob RECONCILE_POLL_INTERVAL 15 1)"
|
||||||
|
|
||||||
while [ $# -gt 0 ]; do
|
while [ $# -gt 0 ]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
|
|||||||
@@ -21,7 +21,9 @@
|
|||||||
# - `export KEY=...`, a key that is not [A-Za-z_][A-Za-z0-9_]*, a line
|
# - `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,
|
# 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;
|
# 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 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).
|
# - 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
|
# SECURITY: never put real secrets in this template. Secret-bearing vars use a
|
||||||
|
|||||||
+1
-1
@@ -366,7 +366,7 @@ The subscriber acts as the central execution watchdog:
|
|||||||
* **`.mam.env` Resolution Hierarchy (`_load_dotenv`)**:
|
* **`.mam.env` Resolution Hierarchy (`_load_dotenv`)**:
|
||||||
Configuration files are resolved with strict precedence rules:
|
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.
|
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()`.
|
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.
|
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`)**:
|
* **Broker Config Resolution (`broker_config_from_job`)**:
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ if [ -f "$MANIFEST_FILE" ]; then
|
|||||||
else
|
else
|
||||||
fallback_assets=(
|
fallback_assets=(
|
||||||
".agents/skills/lib.sh"
|
".agents/skills/lib.sh"
|
||||||
|
".agents/skills/lib_int.sh"
|
||||||
".agents/skills/lib_py"
|
".agents/skills/lib_py"
|
||||||
".agents/skills/multi-agent-mux-create"
|
".agents/skills/multi-agent-mux-create"
|
||||||
".agents/skills/multi-agent-mux-delegate-job"
|
".agents/skills/multi-agent-mux-delegate-job"
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import subprocess
|
|||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from test_issue21_loop_b import LOCAL_ROWS, _drive, _reviews, _sidecars, _state
|
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
|
from test_issue21_phase2_loop_a import (REM, _calls, _prepare, _resume, _rows, _run, _skill, _wt, # noqa: F401
|
||||||
ctl)
|
ctl)
|
||||||
@@ -253,19 +255,46 @@ def test_a7_reviewer_test_output_stays_out_of_the_source_and_the_sandbox(wt_repo
|
|||||||
|
|
||||||
# A8 -------------------------------------------------------------------------
|
# 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
|
main = ctl
|
||||||
wt2 = _wt(main, "wt2")
|
wt2 = _wt(main, "wt2")
|
||||||
_g(main, "worktree", "add", "-q", "--detach", str(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"])
|
holder = subprocess.Popen(["sleep", "60"])
|
||||||
try:
|
try:
|
||||||
lstart = subprocess.run(["ps", "-p", str(holder.pid), "-o", "lstart="], capture_output=True,
|
lstart = _marker_lstart(holder.pid, env)
|
||||||
text=True).stdout.strip()
|
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"
|
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"
|
content = f"pid={holder.pid}\nlstart={lstart}\nstarted={time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())}\n"
|
||||||
marker.write_text(content)
|
marker.write_text(content)
|
||||||
env = {**os.environ, "WORKSPACE_ROOT": str(main), "MAM_LOOP_MARKER": str(marker), "MAM_LOOP_NO_FREEZE": "1"}
|
env["MAM_LOOP_MARKER"] = str(marker)
|
||||||
env.pop("MAM_LOOP_PY", None)
|
|
||||||
res = subprocess.run(["bash", _skill(main, "multi-agent-mux-loop", "scripts", "run_loop.sh"),
|
res = subprocess.run(["bash", _skill(main, "multi-agent-mux-loop", "scripts", "run_loop.sh"),
|
||||||
"--creator", "c", "--task", "t", "--worktree", str(wt2), "--base", "HEAD",
|
"--creator", "c", "--task", "t", "--worktree", str(wt2), "--base", "HEAD",
|
||||||
"--review-only", "--reviewer", "r"],
|
"--review-only", "--reviewer", "r"],
|
||||||
|
|||||||
@@ -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 = main / ".agents" / "skills" / "multi-agent-mux-loop" / "scripts"
|
||||||
dst.mkdir(parents=True)
|
dst.mkdir(parents=True)
|
||||||
(dst / "diff_collect.sh").write_text(DIFF_SH.read_text())
|
(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")
|
monkeypatch.setenv("MAM_DIFF_MAX_LINES", "1")
|
||||||
_state(monkeypatch, LOCAL_ROWS + [REMOTE_ROW])
|
_state(monkeypatch, LOCAL_ROWS + [REMOTE_ROW])
|
||||||
argv = ["--worktree", str(wt), "--base", before, "--review-only", "--remote-review", "diff-only"]
|
argv = ["--worktree", str(wt), "--base", before, "--review-only", "--remote-review", "diff-only"]
|
||||||
|
|||||||
@@ -180,6 +180,47 @@ def test_a1_a_file_that_cannot_be_read_stops_both_loaders(tmp_path, kind):
|
|||||||
f.chmod(0o600)
|
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):
|
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
|
"""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."""
|
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()
|
assert not (tmp_path / "pwned-lib").exists()
|
||||||
got = _wrapper_values(_env(tmp_path, f), ["SCRIPT_DIR", "TARGET_ENV"], tmp_path)
|
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)}
|
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
@@ -43,6 +43,18 @@ INT_KNOBS = {
|
|||||||
"SKS_DELIVERY_GRACE_S": (0, "15"),
|
"SKS_DELIVERY_GRACE_S": (0, "15"),
|
||||||
# _pane_quiescent's `for ((i = 0; i < tries; i++))` (review R2); 0 keeps "headless" (rc 2)
|
# _pane_quiescent's `for ((i = 0; i < tries; i++))` (review R2); 0 keeps "headless" (rc 2)
|
||||||
"SKS_QUIESCENT_TRIES": (0, "20"),
|
"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's ack_s (MAM_ONBOARD_ACK_S / DELEGATE_JOB_ACK_S via create_session.sh)
|
||||||
"delegate_wait_started:ack_s": (0, "180"),
|
"delegate_wait_started:ack_s": (0, "180"),
|
||||||
# wrapper flags
|
# wrapper flags
|
||||||
@@ -53,6 +65,15 @@ INT_KNOBS = {
|
|||||||
"--ack-timeout": (1, None),
|
"--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
|
# The last two are longer than Python's int() digit limit (4300): the rule must
|
||||||
# bound the significant digits before converting (review R3).
|
# bound the significant digits before converting (review R3).
|
||||||
INPUTS = ["30", "0700", "0900", " 30 ", "0", "-5", "abc", "", "1e3", "9999999999", "000000000030",
|
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_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",
|
"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()",
|
"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)",
|
# sleep's fractional seconds are its meaning here (default 0.5): mam_int would refuse them
|
||||||
"SKS_IDLE_RETRIES": "[ -le ] (decimal)", "SKS_IDLE_RETRY_BACKOFF_S": "sleep",
|
"SKS_QUIESCENT_INTERVAL": "sleep, fractional by default (0.5) - left raw",
|
||||||
"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",
|
|
||||||
"DELEGATE_JOB_ACK_S": "create_session.sh -> delegate_wait_started, which runs mam_int on it",
|
"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)
|
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:
|
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,
|
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)
|
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)
|
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
|
# 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():
|
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)
|
calls = re.findall(r"mam_int_knob ([A-Z_]+) (\S+) (\d+)\)", src)
|
||||||
seen = {}
|
seen = {}
|
||||||
for name, default, minimum in calls:
|
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}"
|
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)])
|
@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):
|
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):
|
"""The consuming function with the call site's normalisation (review R2):
|
||||||
|
|||||||
@@ -193,11 +193,20 @@ def _kill(p: subprocess.Popen) -> None:
|
|||||||
p.wait()
|
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"]
|
tok = _record(reg, jid)["dispatch_token"]
|
||||||
return subprocess.Popen([sys.executable, "-m", "lib_py.stall_watch", "--job", jid,
|
argv = ["--job", jid, "--registry-dir", str(reg), "--target", target, "--agent", agent,
|
||||||
"--registry-dir", str(reg), "--target", target, "--agent", agent,
|
"--expect-dispatch-token", tok]
|
||||||
"--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)
|
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):
|
def test_s13_unreliable_agent_status_turns_idle_off_but_keeps_blocked(box):
|
||||||
_, state, reg, env = box
|
_, state, reg, env = box
|
||||||
env["MAM_TEST_UNRELIABLE_AGENT"] = "claude"
|
|
||||||
jid = _job(env, reg)
|
jid = _job(env, reg)
|
||||||
agent = _proc("exec sleep 300")
|
agent = _proc("exec sleep 300")
|
||||||
try:
|
try:
|
||||||
_agent(state, "creator-claude-t", "claude", agent.pid, "idle", _pane("claude"),
|
_agent(state, "creator-claude-t", "claude", agent.pid, "idle", _pane("claude"),
|
||||||
detection="Do you want to proceed?")
|
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)
|
time.sleep(5)
|
||||||
assert _details(reg, jid) == [] and not _prompts(state)
|
assert _details(reg, jid) == [] and not _prompts(state)
|
||||||
st = json.loads(state.read_text())
|
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)
|
_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
|
# scope - the #34 rule reaches the python herdr calls
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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
|
# 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
|
"""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
|
from lib_py import stall_watch
|
||||||
import argparse
|
import argparse
|
||||||
import uuid
|
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):
|
def fake_wait_once(target, until=(), timeout_ms=None, session=None, run=None):
|
||||||
from lib_py.loop import watcher as w
|
from lib_py.loop import watcher as w
|
||||||
ticks.append(1)
|
ticks.append(1)
|
||||||
|
in_wait(len(ticks))
|
||||||
if len(ticks) == 1:
|
if len(ticks) == 1:
|
||||||
return w.WaitOutcome(w.MATCHED, agent={"agent_status": "idle", "state_change_seq": 1, "pane_id": "w1:p1"})
|
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
|
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):
|
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
|
"""Idle past S and the wrapper vanished during the same wait: the watcher
|
||||||
leaves before judging - no notice, no keys, the record untouched."""
|
leaves before judging - no notice, no keys, the record untouched."""
|
||||||
calls = []
|
gone, seen = [], []
|
||||||
|
|
||||||
def alive(pid):
|
def alive(pid):
|
||||||
calls.append(pid)
|
seen.append((len(ticks), not gone))
|
||||||
return len(calls) <= 2 # alive at the first loop head and after the first wait, gone after
|
return not gone
|
||||||
rc, prompts, notes = _during_notice(tmp_path, monkeypatch, lambda rec: None, alive=alive)
|
|
||||||
|
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 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
|
||||||
Reference in new issue
Block a user