fix(issue-26): integer knobs that reach bash arithmetic are read as decimal
bash read MAM_JOB_TIMEOUT_S=0700 as octal 448 (and failed on 0900) while the loop runner read 700. One rule now, with a per-knob minimum: lib.sh mam_int / mam_int_knob and runner._positive_int (blanks allowed, digits only, decimal, min <= n <= 999999999). - minimum 1: MAM_JOB_TIMEOUT_S, MAM_HEARTBEAT_SEC, MAM_HEARTBEAT_MISSES, MAM_ACK_TIMEOUT_S (invalid env: warning + default) and the wrapper's --timeout/--idle-timeout/--heartbeat/--heartbeat-misses/--ack-timeout (invalid: rc 1); the runner's job_budget, --job-timeout, contract_args. - minimum 0 (0 = check once): MAM_INJECT_ACK_GRACE_S, delegate_wait_started's ack_s, SKS_DIALOG_TIMEOUT, SKS_BUSY_TIMEOUT, SKS_DELIVERY_GRACE_S. - tests/test_issue26_ints.py: INT_KNOBS is the one table of minimums; a static guard keeps env defaults out of arithmetic and checks each normalised knob against it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f1331d36ce
commit
80dc2eb45e
4 files changed
+367
-24
No files matched your search
+33
-3
@@ -2335,6 +2335,35 @@ 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() {
|
||||
[[ "${1-}" =~ ^[[:space:]]*([0-9]+)[[:space:]]*$ ]] || 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]
|
||||
#
|
||||
# ISSUE-23 / ISSUE-18: delivery is confirmed by the protocol, not by the
|
||||
@@ -2347,6 +2376,7 @@ sys.exit(1)
|
||||
delegate_wait_started() {
|
||||
local job_id="$1" ack_s="${2:-180}" tok="${3:-}"
|
||||
[ -n "$job_id" ] || return 1
|
||||
ack_s=$(mam_int "$ack_s" 0) || { echo "WARNING: onboarding ack timeout ${2-} ignored (not a decimal integer >= 0); using 180" >&2; ack_s=180; }
|
||||
local deadline
|
||||
deadline=$(( $(date +%s) + ack_s ))
|
||||
while :; do
|
||||
@@ -2759,7 +2789,7 @@ send_keys_safe() {
|
||||
fi
|
||||
|
||||
if [ "$_q_rc" != "2" ]; then
|
||||
deadline=$(( $(date +%s) + ${SKS_DIALOG_TIMEOUT:-30} ))
|
||||
deadline=$(( $(date +%s) + $(mam_int_knob SKS_DIALOG_TIMEOUT 30 0) ))
|
||||
while _pane_dialog_open "$sess"; do
|
||||
if [ "${SKS_DIALOG_ESCAPE:-0}" = "1" ]; then
|
||||
_sks_herdr send-keys -t "$sess" Escape
|
||||
@@ -2778,7 +2808,7 @@ send_keys_safe() {
|
||||
|
||||
# Busy gate: wait up to SKS_BUSY_TIMEOUT only if wait_for_agent_idle returned 3 (unknown)
|
||||
if [ "${_SKS_IDLE_RC:-0}" = "3" ]; then
|
||||
local b_deadline=$(( $(date +%s) + ${SKS_BUSY_TIMEOUT:-120} ))
|
||||
local b_deadline=$(( $(date +%s) + $(mam_int_knob SKS_BUSY_TIMEOUT 120 0) ))
|
||||
local still_busy=1
|
||||
while [ "$(date +%s)" -lt "$b_deadline" ]; do
|
||||
local tail_pane
|
||||
@@ -2847,7 +2877,7 @@ send_keys_safe() {
|
||||
# still fails closed (rc 4); the wrapper then waits for `started` up to
|
||||
# the job's ack timeout T before finalising the inject as failed.
|
||||
if printf '%s\n' "$p_out" | grep -Eq '"code": *"(timeout|agent_prompt_stalled)"'; then
|
||||
local g_deadline=$(( $(date +%s) + ${SKS_DELIVERY_GRACE_S:-15} ))
|
||||
local g_deadline=$(( $(date +%s) + $(mam_int_knob SKS_DELIVERY_GRACE_S 15 0) ))
|
||||
while [ "$(date +%s)" -lt "$g_deadline" ]; do
|
||||
[ "$(_sks_agent_status "$sess" "$agent_target")" = "working" ] && return 0
|
||||
_sks_job_started "$job_id" && return 0
|
||||
|
||||
@@ -67,6 +67,16 @@ def log_error(msg: str) -> None:
|
||||
print(f"{_COLOR_ERROR} {msg}", file=sys.stderr)
|
||||
|
||||
|
||||
|
||||
def _positive_int(text: Optional[str], minimum: int = 1) -> Optional[int]:
|
||||
"""Issue #26: lib.sh mam_int's rule - blanks around, digits only, decimal
|
||||
(0700 is 700), minimum <= n <= 999999999; else None."""
|
||||
s = (text or "").strip(" \t\n\r\v\f")
|
||||
if not s or not s.isascii() or not s.isdigit():
|
||||
return None
|
||||
n = int(s)
|
||||
return n if minimum <= n <= 999_999_999 else None
|
||||
|
||||
class LoopRunner:
|
||||
"""Orchestrates planning, code implementation, and multi-agent peer reviews."""
|
||||
|
||||
@@ -142,7 +152,7 @@ class LoopRunner:
|
||||
" --all-reviewer Enforce PASS verdict from all active reviewer sessions",
|
||||
" --max-loop N Max execution-review corrective loop runs (default: 3)",
|
||||
" --max-rebut N Max rebuttal attempts per review iteration (default: 1, 0: disabled)",
|
||||
" --job-timeout <sec> Budget of each delegated job (default: $MAM_JOB_TIMEOUT_S or 3600)",
|
||||
" --job-timeout <sec> Budget of each delegated job (positive decimal integer; default: $MAM_JOB_TIMEOUT_S or 3600)",
|
||||
" --verbose Print detailed execution timeline traces",
|
||||
" --cleanup Purge temporary job directories upon success",
|
||||
" --worktree <path> Source worktree for diff/base/head (control plane stays here)",
|
||||
@@ -214,10 +224,10 @@ class LoopRunner:
|
||||
self.max_loop = int(args[i + 1])
|
||||
i += 2
|
||||
elif arg == "--job-timeout":
|
||||
if i + 1 >= len(args) or not re.match(r"^[0-9]+$", args[i + 1]) or int(args[i + 1]) <= 0:
|
||||
print("ERROR: --job-timeout requires a positive non-zero integer.", file=sys.stderr)
|
||||
self.job_timeout = _positive_int(args[i + 1]) if i + 1 < len(args) else None
|
||||
if self.job_timeout is None:
|
||||
print("ERROR: --job-timeout requires a positive decimal integer.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
self.job_timeout = int(args[i + 1])
|
||||
i += 2
|
||||
elif arg == "--max-rebut":
|
||||
if i + 1 >= len(args) or not re.match(r"^[0-9]+$", args[i + 1]):
|
||||
@@ -471,9 +481,9 @@ class LoopRunner:
|
||||
for env_name, flag in (("MAM_HEARTBEAT_SEC", "--heartbeat"),
|
||||
("MAM_HEARTBEAT_MISSES", "--heartbeat-misses"),
|
||||
("MAM_ACK_TIMEOUT_S", "--ack-timeout")):
|
||||
val = os.environ.get(env_name)
|
||||
if val and val.isdigit() and int(val) > 0:
|
||||
args += [flag, val]
|
||||
val = _positive_int(os.environ.get(env_name))
|
||||
if val is not None:
|
||||
args += [flag, str(val)]
|
||||
return args
|
||||
|
||||
def resolve_all_reviewers(self, state: Dict[str, Any]) -> List[str]:
|
||||
@@ -593,11 +603,17 @@ class LoopRunner:
|
||||
|
||||
@property
|
||||
def job_budget(self) -> int:
|
||||
"""Issue #33: --job-timeout > $MAM_JOB_TIMEOUT_S (positive integer) > 3600."""
|
||||
"""Issue #33: --job-timeout > $MAM_JOB_TIMEOUT_S (positive decimal integer) > 3600."""
|
||||
if self.job_timeout:
|
||||
return self.job_timeout
|
||||
env = os.environ.get("MAM_JOB_TIMEOUT_S", "")
|
||||
return int(env) if env.isdigit() and int(env) > 0 else 3600
|
||||
n = _positive_int(env)
|
||||
if n is None and env:
|
||||
if not getattr(self, "_warned_job_timeout", False):
|
||||
self._warned_job_timeout = True
|
||||
print(f"WARNING: MAM_JOB_TIMEOUT_S={env} ignored (not a decimal integer >= 1); using 3600", file=sys.stderr)
|
||||
return 3600
|
||||
return n or 3600
|
||||
|
||||
@property
|
||||
def job_wait_s(self) -> int:
|
||||
|
||||
@@ -115,7 +115,7 @@ usage() {
|
||||
multi-agent-mux-delegate-job <command> [options]
|
||||
|
||||
submit --agent <name> (--prompt <text> | --prompt-file <path>) [--workdir <dir>] [--agent-session <label>]
|
||||
[--timeout <sec> (default: $MAM_JOB_TIMEOUT_S or 3600)] [--idle-timeout <sec>] [--validate <script>]
|
||||
[--timeout <sec> (positive decimal integer; default: $MAM_JOB_TIMEOUT_S or 3600)] [--idle-timeout <sec>] [--validate <script>]
|
||||
[--heartbeat <sec>] [--heartbeat-misses <count>] [--ack-timeout <sec>]
|
||||
[--registry-dir <dir>] [--dry-run] [--role <role_name>]
|
||||
[--type <direct|loop|discuss>] [--reviewer <reviewer_agent>]
|
||||
@@ -147,15 +147,10 @@ TIMEOUT=3600; IDLE_TIMEOUT=""; VALIDATE=""; DRY_RUN=0
|
||||
# job_subscriber.py exit codes (its RC_* constants; a test keeps the two equal).
|
||||
SUB_RC_COMPLETED=0; SUB_RC_ERROR=1; SUB_RC_NO_EVENT=2; SUB_RC_BROKER_UNAVAILABLE=3; SUB_RC_WALL_CLOCK=4
|
||||
# Issue #33: the job budget falls back to $MAM_JOB_TIMEOUT_S (--timeout > env > 3600).
|
||||
if [[ -n "${MAM_JOB_TIMEOUT_S:-}" ]]; then
|
||||
if [[ "$MAM_JOB_TIMEOUT_S" =~ ^[0-9]+$ && "$MAM_JOB_TIMEOUT_S" -gt 0 ]]; then
|
||||
TIMEOUT="$MAM_JOB_TIMEOUT_S"
|
||||
else
|
||||
echo "WARNING: MAM_JOB_TIMEOUT_S=$MAM_JOB_TIMEOUT_S ignored (not a positive integer); using 3600" >&2
|
||||
fi
|
||||
fi
|
||||
# Issue #26: integer knobs are read as decimal by mam_int (lib.sh).
|
||||
TIMEOUT=$(mam_int_knob MAM_JOB_TIMEOUT_S 3600 1)
|
||||
# Issue #18 liveness contract (N / K / T). An unset --idle-timeout derives K x N.
|
||||
HEARTBEAT_SEC="${MAM_HEARTBEAT_SEC:-30}"; HEARTBEAT_MISSES="${MAM_HEARTBEAT_MISSES:-4}"; ACK_TIMEOUT="${MAM_ACK_TIMEOUT_S:-180}"
|
||||
HEARTBEAT_SEC=$(mam_int_knob MAM_HEARTBEAT_SEC 30 1); HEARTBEAT_MISSES=$(mam_int_knob MAM_HEARTBEAT_MISSES 4 1); ACK_TIMEOUT=$(mam_int_knob MAM_ACK_TIMEOUT_S 180 1)
|
||||
JOB_ID=""; REGISTRY_DIR="$REGISTRY_DIR_DEFAULT"; DELEGATE_ROLE="Worker"
|
||||
TYPE="direct"; REVIEWER="hermes"; REVIEWER_SESSION="herdr:hermes"; MAX_ITERATIONS=5
|
||||
DEFAULT_COUNTERPART_ROLE="Reviewer"
|
||||
@@ -193,6 +188,13 @@ parse_opts() {
|
||||
*) echo "unknown option: $1" >&2; usage; exit 1;;
|
||||
esac
|
||||
done
|
||||
# Issue #26: a flag value that is not a positive decimal integer is refused.
|
||||
local _flag _var _n
|
||||
for _flag in timeout:TIMEOUT idle-timeout:IDLE_TIMEOUT heartbeat:HEARTBEAT_SEC heartbeat-misses:HEARTBEAT_MISSES ack-timeout:ACK_TIMEOUT; do
|
||||
_var="${_flag#*:}"; [ -n "${!_var}" ] || continue
|
||||
_n=$(mam_int "${!_var}" 1) || { echo "ERROR: --${_flag%%:*} ${!_var}: not a positive decimal integer" >&2; exit 1; }
|
||||
printf -v "$_var" '%s' "$_n"
|
||||
done
|
||||
[ -n "$IDLE_TIMEOUT" ] || IDLE_TIMEOUT=$(( HEARTBEAT_SEC * HEARTBEAT_MISSES ))
|
||||
# lib.sh's send_keys_safe reads the job's own `started` from this registry.
|
||||
export SKS_REGISTRY_DIR="$REGISTRY_DIR"
|
||||
@@ -772,7 +774,7 @@ _wait_started() {
|
||||
}
|
||||
|
||||
# Legacy name retained for callers/tests of the ISSUE-14 guard.
|
||||
_inject_acknowledged() { _wait_started "$1" "${MAM_INJECT_ACK_GRACE_S:-$ACK_TIMEOUT}" "${2:-}"; }
|
||||
_inject_acknowledged() { _wait_started "$1" "$(mam_int_knob MAM_INJECT_ACK_GRACE_S "$ACK_TIMEOUT" 0)" "${2:-}"; }
|
||||
|
||||
# ISSUE-14 / ISSUE-18: herdr's agent_status and the pane busy token describe
|
||||
# the screen, not the protocol. They are no longer liveness *evidence* (a
|
||||
@@ -1112,7 +1114,7 @@ inject_agent() {
|
||||
echo "ERROR: 프롬프트 주입 실패 (send_keys_safe rc=$sks_rc) — 세션 '$sess'" >&2
|
||||
return 1 ;;
|
||||
esac
|
||||
local ack_s="${MAM_INJECT_ACK_GRACE_S:-$ACK_TIMEOUT}"
|
||||
local ack_s="$(mam_int_knob MAM_INJECT_ACK_GRACE_S "$ACK_TIMEOUT" 0)"
|
||||
if ! _wait_started "$job_id" "$ack_s" "${4:-}"; then
|
||||
trap - EXIT
|
||||
echo "ERROR: 프롬프트 주입 실패 — 세션 '$sess': no 'started' within ${ACK_WAITED_S:-$ack_s}s (ack timeout ${ack_s}s, bounded by the wall clock); not re-injecting" >&2
|
||||
@@ -1138,7 +1140,7 @@ remote_dispatch() {
|
||||
echo "ERROR: dispatch publish failed for job $job_id (remote:$sess)" >&2
|
||||
return 1
|
||||
fi
|
||||
local ack_s="${MAM_INJECT_ACK_GRACE_S:-$ACK_TIMEOUT}"
|
||||
local ack_s="$(mam_int_knob MAM_INJECT_ACK_GRACE_S "$ACK_TIMEOUT" 0)"
|
||||
if ! _wait_started "$job_id" "$ack_s" "$sub_log"; then
|
||||
echo "ERROR: remote session '$sess' published no 'started' within ${ACK_WAITED_S:-$ack_s}s (ack timeout ${ack_s}s, bounded by the wall clock); not re-dispatching" >&2
|
||||
return 1
|
||||
|
||||
Reference in new issue
Block a user