refactor(issue-17): item 4 - the delegate-job wrapper's decisions move to Python
The wrapper keeps spawn / wait / kill, key injection and the herdr
diagnostic probe; what it decided in bash is now lib_py/delegation/wrapper.py,
reached through one `_decide` helper:
- finalize_unfinished: the clock choice, the disk grace and broker
re-observation, the close request / label, the close and the event
(finalize-begin / finalize-end; bash runs only the busy probe between them)
- start_stall_watch: whether to watch (MAM_STALL_WATCH, remote, human)
- _map_sub_rc, _rc_for (+ MAM_DELEGATE_FAIL_CLOSED), the subscriber
early-exit check and cmd_wait's rc normalisation
- mam_int / mam_int_knob run lib_py/ints.py, which is also the runner's
_positive_int: one implementation of the Issue #26 rule
No behaviour change: tests/test_issue17_item4_decisions.py runs the old
bash functions (copied verbatim from 7209750) and the new wrapper on the
same inputs and compares output, FINAL_JOB_STATUS and the registry /
publish calls. Three static tests that pinned the old bash literals now
pin the Python entry points instead.
Refs #17
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7209750ac7
commit
63d9c205ff
13 files changed
+1128
-286
No files matched your search
@@ -2,28 +2,18 @@
|
|||||||
# lib_int.sh - the integer rule for knobs (Issue #26), on its own so scripts
|
# 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
|
# 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
|
# runner and the tests source alone) read their knobs the same way. lib.sh
|
||||||
# sources it. Functions only: sourcing it has no other effect.
|
# sources it. Sourcing it defines the functions and the path they run.
|
||||||
|
#
|
||||||
|
# Issue #17 item 4: the rule itself is lib_py/ints.py, which the loop runner
|
||||||
|
# imports too (runner.py::_positive_int); these functions only call it.
|
||||||
|
_MAM_INTS_PY="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib_py/ints.py"
|
||||||
|
|
||||||
# mam_int <value> <min>
|
# mam_int <value> <min>
|
||||||
#
|
#
|
||||||
# Issue #26: the one integer rule for knobs that reach bash arithmetic (the
|
# Blanks around, digits only, read as DECIMAL, min <= n <= 999999999 (see
|
||||||
# Python twin is lib_py/loop/runner.py::_positive_int). Surrounding blanks are
|
# lib_py/ints.py). Prints n and returns 0, or returns 1.
|
||||||
# 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() {
|
mam_int() {
|
||||||
# Spelled-out ASCII sets, not [[:space:]]/[0-9]: those follow the locale, and
|
python3 -I "$_MAM_INTS_PY" int "${1-}" "$2"
|
||||||
# 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>
|
# mam_int_knob <ENV_NAME> <default> <min>
|
||||||
@@ -31,9 +21,5 @@ mam_int() {
|
|||||||
# The value of $ENV_NAME through mam_int; unset or empty gives <default>, an
|
# The value of $ENV_NAME through mam_int; unset or empty gives <default>, an
|
||||||
# invalid value gives <default> with a warning on stderr.
|
# invalid value gives <default> with a warning on stderr.
|
||||||
mam_int_knob() {
|
mam_int_knob() {
|
||||||
local raw="${!1-}"
|
python3 -I "$_MAM_INTS_PY" knob "$1" "${!1-}" "$2" "$3"
|
||||||
[ -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"
|
|
||||||
}
|
}
|
||||||
@@ -208,7 +208,7 @@ def record_local_event(prepared: PreparedEvent, registry_dir: str) -> None:
|
|||||||
"""Append the event to the job's on-disk events.log BEFORE any wire publish.
|
"""Append the event to the job's on-disk events.log BEFORE any wire publish.
|
||||||
|
|
||||||
The delegate wrapper reads this log as liveness evidence once its
|
The delegate wrapper reads this log as liveness evidence once its
|
||||||
subscriber has idled out (`_recent_disk_heartbeat`). Recorded only after
|
subscriber has idled out (`wrapper.recent_disk_heartbeat`). Recorded only after
|
||||||
the publish - which blocks on the broker ACK and its retries - a slow
|
the publish - which blocks on the broker ACK and its retries - a slow
|
||||||
broker's heartbeat reached the disk too late for that grace window, and
|
broker's heartbeat reached the disk too late for that grace window, and
|
||||||
live jobs were closed as deadline_timeout. Best effort, like the rest of
|
live jobs were closed as deadline_timeout. Best effort, like the rest of
|
||||||
|
|||||||
@@ -0,0 +1,296 @@
|
|||||||
|
"""Issue #17 item 4: the delegate-job wrapper's decisions.
|
||||||
|
|
||||||
|
The bash wrapper (multi-agent-mux-delegate-job) spawns, waits, kills and
|
||||||
|
injects; everything it used to decide in bash is here, and it branches only on
|
||||||
|
what a subcommand prints or returns:
|
||||||
|
|
||||||
|
sub-status <rc> --job J --registry-dir R subscriber rc -> job status (was _map_sub_rc)
|
||||||
|
subscriber-up <rc> exit 0: a subscriber that ended before
|
||||||
|
SUBSCRIBED still lets the job proceed
|
||||||
|
exit-code <status> the wrapper's exit code (was _rc_for, with
|
||||||
|
MAM_DELEGATE_FAIL_CLOSED=0 -> 0)
|
||||||
|
wait-rc <rc> `wait`'s rc (either clock is a timeout, 2)
|
||||||
|
stall-watch <session> <agent> exit 0: start the stall watcher (Issue #30)
|
||||||
|
finalize-begin / finalize-end ... finalize_unfinished, split around the one
|
||||||
|
herdr diagnostic probe bash still runs
|
||||||
|
|
||||||
|
finalize-end answers on fd 3 (stdout when fd 3 is closed): its stdout carries
|
||||||
|
registry.py's close output to the wrapper's stdout, as before.
|
||||||
|
|
||||||
|
Stdlib only and Python 3.9 (macOS /usr/bin/python3); registry.py and
|
||||||
|
publish_event.py run as the processes the wrapper ran, with its interpreter.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import calendar
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
# job_subscriber.py's RC_* (a test keeps them equal; importing it would load paho).
|
||||||
|
SUB_RC_COMPLETED, SUB_RC_ERROR, SUB_RC_NO_EVENT, SUB_RC_BROKER_UNAVAILABLE, SUB_RC_WALL_CLOCK = 0, 1, 2, 3, 4
|
||||||
|
|
||||||
|
TERMINAL = ("completed", "error", "cancelled")
|
||||||
|
EXIT_CODES = {"completed": 0, "error": 1, "timeout": 2, "broker_unavailable": 3, "cancelled": 5}
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> int:
|
||||||
|
return int(time.time()) # bash's $(date +%s)
|
||||||
|
|
||||||
|
|
||||||
|
def disk_status(registry_dir: str, job_id: str) -> str:
|
||||||
|
"""The record's status, "" when it is missing or unreadable."""
|
||||||
|
try:
|
||||||
|
with open(os.path.join(registry_dir, f"{job_id}.json")) as fh:
|
||||||
|
return str(json.load(fh).get("status", ""))
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def sub_status(rc: int, registry_dir: str, job_id: str) -> str:
|
||||||
|
if rc == SUB_RC_COMPLETED:
|
||||||
|
return "completed"
|
||||||
|
if rc == SUB_RC_ERROR:
|
||||||
|
return "error"
|
||||||
|
if rc == SUB_RC_BROKER_UNAVAILABLE:
|
||||||
|
disk = disk_status(registry_dir, job_id)
|
||||||
|
return disk if disk in TERMINAL else "broker_unavailable"
|
||||||
|
return "timeout" # SUB_RC_NO_EVENT, SUB_RC_WALL_CLOCK (and any other code)
|
||||||
|
|
||||||
|
|
||||||
|
def subscriber_up(rc: int) -> bool:
|
||||||
|
return rc in (SUB_RC_COMPLETED, SUB_RC_BROKER_UNAVAILABLE)
|
||||||
|
|
||||||
|
|
||||||
|
def fail_closed() -> bool:
|
||||||
|
return (os.environ.get("MAM_DELEGATE_FAIL_CLOSED") or "1") != "0"
|
||||||
|
|
||||||
|
|
||||||
|
def exit_code(status: str) -> int:
|
||||||
|
return EXIT_CODES.get(status, 1) if fail_closed() else 0
|
||||||
|
|
||||||
|
|
||||||
|
def wait_rc(rc: int) -> int:
|
||||||
|
return SUB_RC_NO_EVENT if rc == SUB_RC_WALL_CLOCK else rc
|
||||||
|
|
||||||
|
|
||||||
|
def _bare(session: str) -> str:
|
||||||
|
return session[len("herdr:"):] if session.startswith("herdr:") else session
|
||||||
|
|
||||||
|
|
||||||
|
def stall_watch(session: str, agent: str) -> bool:
|
||||||
|
"""A remote or human job has no local pane to watch."""
|
||||||
|
if (os.environ.get("MAM_STALL_WATCH") or "1") == "0":
|
||||||
|
return False
|
||||||
|
if session.startswith("remote:"):
|
||||||
|
print("stall watch: remote job, no local pane - stall detection off", file=sys.stderr)
|
||||||
|
return False
|
||||||
|
if agent == "human" or _bare(session) == "human":
|
||||||
|
print("stall watch: human job, no local pane - stall detection off", file=sys.stderr)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def recent_disk_heartbeat(registry_dir: str, job_id: str, window: float) -> bool:
|
||||||
|
"""ISSUE-14: publish_event appends every event to the job's events.log
|
||||||
|
before its wire publish, so a heartbeat the subscriber never saw is
|
||||||
|
visible here; true when the last one is at most `window` s old."""
|
||||||
|
try:
|
||||||
|
last = ""
|
||||||
|
with open(os.path.join(registry_dir, f"{job_id}.events.log")) as fh:
|
||||||
|
for line in fh:
|
||||||
|
if line.strip():
|
||||||
|
last = line
|
||||||
|
seen = calendar.timegm(time.strptime(json.loads(last)["timestamp"], "%Y-%m-%dT%H:%M:%SZ"))
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
return time.time() - seen <= window
|
||||||
|
|
||||||
|
|
||||||
|
def _tail(path: str, n: int) -> List[str]:
|
||||||
|
with open(path, errors="replace") as fh:
|
||||||
|
return fh.read().splitlines()[-n:]
|
||||||
|
|
||||||
|
|
||||||
|
def finalize_begin(a: argparse.Namespace) -> str:
|
||||||
|
"""`keep` (FINAL_JOB_STATUS untouched), `done <final>`, or
|
||||||
|
`go|probe <final> <evidence_until> <deadline>`."""
|
||||||
|
if not fail_closed() or a.status == "completed":
|
||||||
|
return "keep"
|
||||||
|
stall_out = os.path.join(a.registry_dir, f"{a.job}.stall.out")
|
||||||
|
if os.path.isfile(stall_out) and os.path.getsize(stall_out) > 0:
|
||||||
|
# Issue #30: what the stall watcher saw goes with an unfinished job's close.
|
||||||
|
print(f"stall watch log for {a.job} (last 3 lines):", file=sys.stderr)
|
||||||
|
try:
|
||||||
|
for line in _tail(stall_out, 3):
|
||||||
|
print(line, file=sys.stderr)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
disk = disk_status(a.registry_dir, a.job)
|
||||||
|
if disk == "completed":
|
||||||
|
return "done completed"
|
||||||
|
final = disk if disk in TERMINAL else a.status
|
||||||
|
now = _now()
|
||||||
|
deadline = (a.start_time if a.start_time is not None else now) + a.timeout
|
||||||
|
evidence_until = now + a.idle_timeout
|
||||||
|
sess = _bare(a.session)
|
||||||
|
# herdr status and the pane are a diagnostic only (ISSUE-14 / ISSUE-18):
|
||||||
|
# bash runs that probe, bounded by the deadline, when asked to.
|
||||||
|
probe = (a.status == "timeout" and a.agent != "human" and sess != "human" and sess != ""
|
||||||
|
and not a.session.startswith("remote:"))
|
||||||
|
return f"{'probe' if probe else 'go'} {final} {evidence_until} {deadline}"
|
||||||
|
|
||||||
|
|
||||||
|
def _run(a: argparse.Namespace, script: str, args: List[str], merge: bool) -> int:
|
||||||
|
sys.stdout.flush()
|
||||||
|
cmd = [a.python, os.path.join(a.scripts_dir, script), "--registry-dir", a.registry_dir, *args]
|
||||||
|
return subprocess.call(cmd, stderr=subprocess.STDOUT if merge else subprocess.DEVNULL)
|
||||||
|
|
||||||
|
|
||||||
|
def finalize_end(a: argparse.Namespace) -> str:
|
||||||
|
"""The rest of finalize_unfinished; returns FINAL_JOB_STATUS."""
|
||||||
|
job, reg, status, final = a.job, a.registry_dir, a.status, a.final
|
||||||
|
clock = ""
|
||||||
|
deadline = (a.start_time if a.start_time is not None else _now()) + a.timeout
|
||||||
|
if status == "broker_unavailable":
|
||||||
|
# Re-observe the disk at 3 s intervals until this dispatch's wall-clock
|
||||||
|
# deadline, read off the clock on every pass.
|
||||||
|
while True:
|
||||||
|
left = deadline - _now()
|
||||||
|
if left <= 0:
|
||||||
|
break
|
||||||
|
time.sleep(min(left, 3))
|
||||||
|
disk = disk_status(reg, job)
|
||||||
|
if disk in TERMINAL:
|
||||||
|
status = final = disk
|
||||||
|
if disk == "completed":
|
||||||
|
return final
|
||||||
|
break
|
||||||
|
if _now() >= deadline: # Issue #33 (review R3): a wait into the deadline is the wall clock
|
||||||
|
clock = "wall"
|
||||||
|
if status == "timeout":
|
||||||
|
# ISSUE-14 / ISSUE-18: keep re-observing the disk while the contract
|
||||||
|
# says the agent is alive - an event in events.log at most IDLE_TIMEOUT
|
||||||
|
# (K x N) old. Entering this grace opens one window and every disk event
|
||||||
|
# re-opens it; evidence never extends the wall clock.
|
||||||
|
evidence_until = a.evidence_until
|
||||||
|
while _now() < deadline:
|
||||||
|
disk = disk_status(reg, job)
|
||||||
|
if disk in TERMINAL:
|
||||||
|
status = final = disk
|
||||||
|
if disk == "completed":
|
||||||
|
return final
|
||||||
|
break
|
||||||
|
if recent_disk_heartbeat(reg, job, a.idle_timeout):
|
||||||
|
evidence_until = _now() + a.idle_timeout
|
||||||
|
elif _now() >= evidence_until:
|
||||||
|
break
|
||||||
|
if deadline - _now() <= 0:
|
||||||
|
break
|
||||||
|
time.sleep(1)
|
||||||
|
if _now() >= deadline:
|
||||||
|
clock = "wall"
|
||||||
|
|
||||||
|
# Issue #33: the subscriber's rc says which clock ran out (4 wall clock,
|
||||||
|
# 2 no event); a grace above that ends on the deadline is the wall clock too.
|
||||||
|
clock = clock or "idle"
|
||||||
|
if a.sub_rc == SUB_RC_WALL_CLOCK:
|
||||||
|
clock = "wall"
|
||||||
|
close_req = "wall_clock_timeout" if clock == "wall" and a.phase != "inject" else "deadline_timeout"
|
||||||
|
detail = ""
|
||||||
|
if a.phase in ("preflight", "idle_wait"):
|
||||||
|
close_req, detail = "cancelled", "session_busy"
|
||||||
|
# Issue #34: a target the preflight could not see was never busy
|
||||||
|
if a.phase == "preflight" and a.preflight_reason:
|
||||||
|
detail = a.preflight_reason
|
||||||
|
if close_req != "cancelled":
|
||||||
|
print(f"finalize: {job} closed by {'wall_clock' if clock == 'wall' else 'no_event'} clock "
|
||||||
|
f"(budget {a.timeout}s / idle {a.idle_timeout}s)", file=sys.stderr)
|
||||||
|
tok = ["--expect-dispatch-token", a.dispatch_token] if a.dispatch_token else []
|
||||||
|
close_args = ["close", "--job", job, "--request", close_req] + (["--detail", detail] if detail else []) + tok
|
||||||
|
if _run(a, "registry.py", close_args, merge=True) == 4:
|
||||||
|
# a dispatch token mismatch (stale attempt): no secondary event (CAS/ABA)
|
||||||
|
print("finalize_unfinished: dispatch token mismatch (stale attempt); suppressing event emission",
|
||||||
|
file=sys.stderr)
|
||||||
|
return final
|
||||||
|
if disk_status(reg, job) == "completed":
|
||||||
|
return "completed"
|
||||||
|
# direct path only: publish the error/cancelled event (loop/discuss forbidden: O-1)
|
||||||
|
if a.type == "direct":
|
||||||
|
if close_req == "cancelled":
|
||||||
|
_run(a, "publish_event.py", ["--job", job, "--event", "cancelled", "--detail", detail] + tok, merge=False)
|
||||||
|
elif a.sub_rc != SUB_RC_ERROR or a.phase == "inject":
|
||||||
|
err_detail = "agent_blocked" if a.phase == "inject" else close_req
|
||||||
|
_run(a, "publish_event.py", ["--job", job, "--event", "error", "--detail", err_detail] + tok, merge=False)
|
||||||
|
return final
|
||||||
|
|
||||||
|
|
||||||
|
def _answer(text: str) -> None:
|
||||||
|
try:
|
||||||
|
os.write(3, (text + "\n").encode())
|
||||||
|
except OSError:
|
||||||
|
print(text)
|
||||||
|
|
||||||
|
|
||||||
|
def _parser() -> argparse.ArgumentParser:
|
||||||
|
p = argparse.ArgumentParser(prog="python -m lib_py.delegation.wrapper")
|
||||||
|
sub = p.add_subparsers(dest="cmd", required=True)
|
||||||
|
s = sub.add_parser("sub-status")
|
||||||
|
s.add_argument("rc", type=int)
|
||||||
|
s.add_argument("--job", required=True)
|
||||||
|
s.add_argument("--registry-dir", required=True)
|
||||||
|
sub.add_parser("subscriber-up").add_argument("rc", type=int)
|
||||||
|
sub.add_parser("exit-code").add_argument("status")
|
||||||
|
sub.add_parser("wait-rc").add_argument("rc", type=int)
|
||||||
|
s = sub.add_parser("stall-watch")
|
||||||
|
s.add_argument("session")
|
||||||
|
s.add_argument("agent")
|
||||||
|
for name in ("finalize-begin", "finalize-end"):
|
||||||
|
s = sub.add_parser(name)
|
||||||
|
s.add_argument("--job", required=True)
|
||||||
|
s.add_argument("--registry-dir", required=True)
|
||||||
|
s.add_argument("--status", required=True)
|
||||||
|
s.add_argument("--start-time", type=int, default=None)
|
||||||
|
s.add_argument("--timeout", type=int, required=True)
|
||||||
|
s.add_argument("--idle-timeout", type=int, required=True)
|
||||||
|
if name == "finalize-begin":
|
||||||
|
s.add_argument("--agent", default="")
|
||||||
|
s.add_argument("--session", default="")
|
||||||
|
else:
|
||||||
|
s.add_argument("--sub-rc", type=int, required=True)
|
||||||
|
s.add_argument("--phase", default="")
|
||||||
|
s.add_argument("--type", default="direct")
|
||||||
|
s.add_argument("--final", required=True)
|
||||||
|
s.add_argument("--evidence-until", type=int, default=0)
|
||||||
|
s.add_argument("--dispatch-token", default="")
|
||||||
|
s.add_argument("--preflight-reason", default="")
|
||||||
|
s.add_argument("--python", default=sys.executable)
|
||||||
|
s.add_argument("--scripts-dir", required=True)
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: Optional[List[str]] = None) -> int:
|
||||||
|
a = _parser().parse_args(argv)
|
||||||
|
if a.cmd == "sub-status":
|
||||||
|
print(sub_status(a.rc, a.registry_dir, a.job))
|
||||||
|
elif a.cmd == "subscriber-up":
|
||||||
|
return 0 if subscriber_up(a.rc) else 1
|
||||||
|
elif a.cmd == "exit-code":
|
||||||
|
print(exit_code(a.status))
|
||||||
|
elif a.cmd == "wait-rc":
|
||||||
|
print(wait_rc(a.rc))
|
||||||
|
elif a.cmd == "stall-watch":
|
||||||
|
return 0 if stall_watch(a.session, a.agent) else 1
|
||||||
|
elif a.cmd == "finalize-begin":
|
||||||
|
print(finalize_begin(a))
|
||||||
|
else:
|
||||||
|
_answer(finalize_end(a))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""Issue #26 / #17 item 4: the one integer rule for knobs and flags.
|
||||||
|
|
||||||
|
lib_int.sh's mam_int / mam_int_knob run this file, and the loop runner's
|
||||||
|
_positive_int is positive_int below, so bash and Python share one
|
||||||
|
implementation. Surrounding ASCII blanks are allowed, then ASCII digits only,
|
||||||
|
read as DECIMAL (bash would read 0700 as octal 448), and
|
||||||
|
minimum <= n <= 999999999. The per-knob minimum lives in
|
||||||
|
tests/test_issue26_ints.py.
|
||||||
|
|
||||||
|
Stdlib only and Python 3.9: lib_int.sh runs it as a script (python3 -I),
|
||||||
|
where lib_py is not importable.
|
||||||
|
|
||||||
|
ints.py int <value> <min> print n, or exit 1
|
||||||
|
ints.py knob <name> <value> <default> <min> print n; empty gives <default>,
|
||||||
|
invalid gives <default> + a warning
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
_BLANKS = " \t\n\r\v\f"
|
||||||
|
|
||||||
|
|
||||||
|
def positive_int(text: Optional[str], minimum: int = 1) -> Optional[int]:
|
||||||
|
"""n when `text` is a decimal integer >= minimum (and <= 999999999), else None."""
|
||||||
|
s = (text or "").strip(_BLANKS)
|
||||||
|
if not s or not s.isascii() or not s.isdigit():
|
||||||
|
return None
|
||||||
|
s = s.lstrip("0") or "0"
|
||||||
|
if len(s) > 9: # bounded before int(), which refuses > 4300 digits
|
||||||
|
return None
|
||||||
|
n = int(s)
|
||||||
|
return n if n >= minimum else None
|
||||||
|
|
||||||
|
|
||||||
|
def knob(name: str, raw: str, default: str, minimum: int) -> str:
|
||||||
|
"""mam_int_knob: the value of $name by the rule, <default> when empty or invalid."""
|
||||||
|
if not raw:
|
||||||
|
return default
|
||||||
|
n = positive_int(raw, minimum)
|
||||||
|
if n is not None:
|
||||||
|
return str(n)
|
||||||
|
print(f"WARNING: {name}={raw} ignored (not a decimal integer >= {minimum}); using {default}", file=sys.stderr)
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: List[str]) -> int:
|
||||||
|
if len(argv) == 3 and argv[0] == "int":
|
||||||
|
n = positive_int(argv[1], int(argv[2]))
|
||||||
|
if n is None:
|
||||||
|
return 1
|
||||||
|
print(n)
|
||||||
|
return 0
|
||||||
|
if len(argv) == 5 and argv[0] == "knob":
|
||||||
|
print(knob(argv[1], argv[2], argv[3], int(argv[4])))
|
||||||
|
return 0
|
||||||
|
print("usage: ints.py int <value> <min> | knob <name> <value> <default> <min>", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main(sys.argv[1:]))
|
||||||
@@ -46,6 +46,7 @@ CORE_MODULES: Sequence[str] = (
|
|||||||
"lib_py.loop.store",
|
"lib_py.loop.store",
|
||||||
"lib_py.loop.watcher",
|
"lib_py.loop.watcher",
|
||||||
# Delegation and common modules
|
# Delegation and common modules
|
||||||
|
"lib_py.ints",
|
||||||
"lib_py.paths",
|
"lib_py.paths",
|
||||||
"lib_py.state",
|
"lib_py.state",
|
||||||
"lib_py.atomic_yaml",
|
"lib_py.atomic_yaml",
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import time
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||||
|
|
||||||
|
from lib_py import ints
|
||||||
from lib_py.delegation import registry as job_registry
|
from lib_py.delegation import registry as job_registry
|
||||||
from lib_py.loop import checkpoint, eager, parser
|
from lib_py.loop import checkpoint, eager, parser
|
||||||
|
|
||||||
@@ -68,17 +69,8 @@ def log_error(msg: str) -> None:
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _positive_int(text: Optional[str], minimum: int = 1) -> Optional[int]:
|
# Issue #17 item 4: lib.sh mam_int's rule, one implementation (lib_py/ints.py).
|
||||||
"""Issue #26: lib.sh mam_int's rule - blanks around, digits only, decimal
|
_positive_int = ints.positive_int
|
||||||
(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
|
|
||||||
s = s.lstrip("0") or "0"
|
|
||||||
if len(s) > 9: # like mam_int: bounded before int(), which refuses > 4300 digits
|
|
||||||
return None
|
|
||||||
n = int(s)
|
|
||||||
return n if n >= minimum else None
|
|
||||||
|
|
||||||
class LoopRunner:
|
class LoopRunner:
|
||||||
"""Orchestrates planning, code implementation, and multi-agent peer reviews."""
|
"""Orchestrates planning, code implementation, and multi-agent peer reviews."""
|
||||||
|
|||||||
@@ -94,6 +94,12 @@ _logs_dir_abs() {
|
|||||||
"$PY" -c 'import os, sys; sys.path.insert(0, sys.argv[1]); from lib_py.delegation.mqtt_common import get_logs_dir; print(os.path.abspath(get_logs_dir()))' "$SCRIPT_DIR/.."
|
"$PY" -c 'import os, sys; sys.path.insert(0, sys.argv[1]); from lib_py.delegation.mqtt_common import get_logs_dir; print(os.path.abspath(get_logs_dir()))' "$SCRIPT_DIR/.."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Issue #17 item 4: the wrapper's decisions are lib_py/delegation/wrapper.py;
|
||||||
|
# bash only branches on what a subcommand prints or returns.
|
||||||
|
_decide() {
|
||||||
|
PYTHONPATH="$SCRIPT_DIR/..${PYTHONPATH:+:$PYTHONPATH}" "$PY" -m lib_py.delegation.wrapper "$@"
|
||||||
|
}
|
||||||
|
|
||||||
# _pub_cmd <extra...>: the publish command as one shell-quoted string.
|
# _pub_cmd <extra...>: the publish command as one shell-quoted string.
|
||||||
_pub_cmd() {
|
_pub_cmd() {
|
||||||
local s
|
local s
|
||||||
@@ -349,21 +355,18 @@ EOF
|
|||||||
else
|
else
|
||||||
local sub_exit=0
|
local sub_exit=0
|
||||||
wait "$sub_pid" 2>/dev/null || sub_exit=$?
|
wait "$sub_pid" 2>/dev/null || sub_exit=$?
|
||||||
if [ $sub_exit -eq $SUB_RC_COMPLETED ] || [ $sub_exit -eq $SUB_RC_BROKER_UNAVAILABLE ]; then
|
if _decide subscriber-up "$sub_exit"; then
|
||||||
sub_ready=1
|
sub_ready=1
|
||||||
break
|
break
|
||||||
else
|
else
|
||||||
echo "ERROR: subscriber died early (pid=$sub_pid, exit=$sub_exit, check $logf)" >&2
|
echo "ERROR: subscriber died early (pid=$sub_pid, exit=$sub_exit, check $logf)" >&2
|
||||||
local mapped_st
|
local mapped_st
|
||||||
mapped_st="$(_map_sub_rc "$sub_exit" "$JOB_ID")"
|
mapped_st="$(_decide sub-status "$sub_exit" --job "$JOB_ID" --registry-dir "$REGISTRY_DIR")"
|
||||||
finalize_unfinished "$JOB_ID" "$mapped_st" "$sub_exit" "subscriber"
|
finalize_unfinished "$JOB_ID" "$mapped_st" "$sub_exit" "subscriber"
|
||||||
mapped_st="${FINAL_JOB_STATUS:-$mapped_st}"
|
mapped_st="${FINAL_JOB_STATUS:-$mapped_st}"
|
||||||
local logs_root="${DELEGATE_JOB_LOGS_DIR:-$WORKDIR/delegate_job_logs}"
|
local logs_root="${DELEGATE_JOB_LOGS_DIR:-$WORKDIR/delegate_job_logs}"
|
||||||
echo "$logs_root/$JOB_ID"
|
echo "$logs_root/$JOB_ID"
|
||||||
if [ "${MAM_DELEGATE_FAIL_CLOSED:-1}" = "0" ]; then
|
exit "$(_decide exit-code "$mapped_st")"
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
exit "$(_rc_for "$mapped_st")"
|
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
sleep 0.2
|
sleep 0.2
|
||||||
@@ -399,7 +402,7 @@ EOF
|
|||||||
echo "subscriber output:"; cat "$logf" || true
|
echo "subscriber output:"; cat "$logf" || true
|
||||||
|
|
||||||
local job_status
|
local job_status
|
||||||
job_status="$(_map_sub_rc "$sub_rc" "$JOB_ID")"
|
job_status="$(_decide sub-status "$sub_rc" --job "$JOB_ID" --registry-dir "$REGISTRY_DIR")"
|
||||||
|
|
||||||
finalize_unfinished "$JOB_ID" "$job_status" "$sub_rc" "direct"
|
finalize_unfinished "$JOB_ID" "$job_status" "$sub_rc" "direct"
|
||||||
job_status="${FINAL_JOB_STATUS:-$job_status}"
|
job_status="${FINAL_JOB_STATUS:-$job_status}"
|
||||||
@@ -408,10 +411,7 @@ EOF
|
|||||||
# "Audit Logs"). Callers can scrape `tail -n1` to find it.
|
# "Audit Logs"). Callers can scrape `tail -n1` to find it.
|
||||||
local logs_root="${DELEGATE_JOB_LOGS_DIR:-$WORKDIR/delegate_job_logs}"
|
local logs_root="${DELEGATE_JOB_LOGS_DIR:-$WORKDIR/delegate_job_logs}"
|
||||||
echo "$logs_root/$JOB_ID"
|
echo "$logs_root/$JOB_ID"
|
||||||
if [ "${MAM_DELEGATE_FAIL_CLOSED:-1}" = "0" ]; then
|
exit "$(_decide exit-code "$job_status")"
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
exit "$(_rc_for "$job_status")"
|
|
||||||
else
|
else
|
||||||
# Implement loop/discuss orchestrator
|
# Implement loop/discuss orchestrator
|
||||||
local iteration=1
|
local iteration=1
|
||||||
@@ -527,21 +527,18 @@ EOF
|
|||||||
else
|
else
|
||||||
local sub_exit=0
|
local sub_exit=0
|
||||||
wait "$sub_pid" 2>/dev/null || sub_exit=$?
|
wait "$sub_pid" 2>/dev/null || sub_exit=$?
|
||||||
if [ $sub_exit -eq $SUB_RC_COMPLETED ] || [ $sub_exit -eq $SUB_RC_BROKER_UNAVAILABLE ]; then
|
if _decide subscriber-up "$sub_exit"; then
|
||||||
sub_ready=1
|
sub_ready=1
|
||||||
break
|
break
|
||||||
else
|
else
|
||||||
echo "ERROR: subscriber died early (pid=$sub_pid, exit=$sub_exit, check $logf)" >&2
|
echo "ERROR: subscriber died early (pid=$sub_pid, exit=$sub_exit, check $logf)" >&2
|
||||||
local mapped_st
|
local mapped_st
|
||||||
mapped_st="$(_map_sub_rc "$sub_exit" "$JOB_ID")"
|
mapped_st="$(_decide sub-status "$sub_exit" --job "$JOB_ID" --registry-dir "$REGISTRY_DIR")"
|
||||||
finalize_unfinished "$JOB_ID" "$mapped_st" "$sub_exit" "subscriber"
|
finalize_unfinished "$JOB_ID" "$mapped_st" "$sub_exit" "subscriber"
|
||||||
mapped_st="${FINAL_JOB_STATUS:-$mapped_st}"
|
mapped_st="${FINAL_JOB_STATUS:-$mapped_st}"
|
||||||
local logs_root="${DELEGATE_JOB_LOGS_DIR:-$WORKDIR/delegate_job_logs}"
|
local logs_root="${DELEGATE_JOB_LOGS_DIR:-$WORKDIR/delegate_job_logs}"
|
||||||
echo "$logs_root/$JOB_ID"
|
echo "$logs_root/$JOB_ID"
|
||||||
if [ "${MAM_DELEGATE_FAIL_CLOSED:-1}" = "0" ]; then
|
exit "$(_decide exit-code "$mapped_st")"
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
exit "$(_rc_for "$mapped_st")"
|
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
sleep 0.2
|
sleep 0.2
|
||||||
@@ -574,7 +571,7 @@ EOF
|
|||||||
echo "subscriber output:"; cat "$logf" || true
|
echo "subscriber output:"; cat "$logf" || true
|
||||||
|
|
||||||
local job_status
|
local job_status
|
||||||
job_status="$(_map_sub_rc "$sub_rc" "$JOB_ID")"
|
job_status="$(_decide sub-status "$sub_rc" --job "$JOB_ID" --registry-dir "$REGISTRY_DIR")"
|
||||||
|
|
||||||
echo "Job role $display_role finished with status: $job_status"
|
echo "Job role $display_role finished with status: $job_status"
|
||||||
|
|
||||||
@@ -593,10 +590,7 @@ EOF
|
|||||||
else
|
else
|
||||||
local logs_root="${DELEGATE_JOB_LOGS_DIR:-$WORKDIR/delegate_job_logs}"
|
local logs_root="${DELEGATE_JOB_LOGS_DIR:-$WORKDIR/delegate_job_logs}"
|
||||||
echo "$logs_root/$JOB_ID"
|
echo "$logs_root/$JOB_ID"
|
||||||
if [ "${MAM_DELEGATE_FAIL_CLOSED:-1}" = "0" ]; then
|
exit "$(_decide exit-code "$job_status")"
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
exit "$(_rc_for "$job_status")"
|
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -621,10 +615,7 @@ EOF
|
|||||||
else
|
else
|
||||||
local logs_root="${DELEGATE_JOB_LOGS_DIR:-$WORKDIR/delegate_job_logs}"
|
local logs_root="${DELEGATE_JOB_LOGS_DIR:-$WORKDIR/delegate_job_logs}"
|
||||||
echo "$logs_root/$JOB_ID"
|
echo "$logs_root/$JOB_ID"
|
||||||
if [ "${MAM_DELEGATE_FAIL_CLOSED:-1}" = "0" ]; then
|
exit "$(_decide exit-code "$job_status")"
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
exit "$(_rc_for "$job_status")"
|
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -692,18 +683,13 @@ reap_subscriber() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Issue #30: a notify-only stall watcher for a job with a local herdr pane
|
# Issue #30: a notify-only stall watcher for a job with a local herdr pane
|
||||||
# (lib_py.stall_watch decides; it never closes the job). A remote
|
# (lib_py.stall_watch decides; it never closes the job). Whether to watch
|
||||||
# or human job has no pane to watch. Sets STALL_PID ("" when none started).
|
# (MAM_STALL_WATCH, remote, human) is `_decide stall-watch`. Sets STALL_PID
|
||||||
|
# ("" when none started).
|
||||||
start_stall_watch() {
|
start_stall_watch() {
|
||||||
local session="$1" agent="$2" tok="${3:-}" att="${4:-}"
|
local session="$1" agent="$2" tok="${3:-}" att="${4:-}"
|
||||||
STALL_PID=""
|
STALL_PID=""
|
||||||
[ "${MAM_STALL_WATCH:-1}" = "0" ] && return 0
|
_decide stall-watch "$session" "$agent" || return 0
|
||||||
if _is_remote "$session"; then
|
|
||||||
echo "stall watch: remote job, no local pane - stall detection off" >&2; return 0
|
|
||||||
fi
|
|
||||||
if [ "$agent" = "human" ] || [ "${session#herdr:}" = "human" ]; then
|
|
||||||
echo "stall watch: human job, no local pane - stall detection off" >&2; return 0
|
|
||||||
fi
|
|
||||||
local args=(--job "$JOB_ID" --registry-dir "$REGISTRY_DIR" --target "${session#herdr:}"
|
local args=(--job "$JOB_ID" --registry-dir "$REGISTRY_DIR" --target "${session#herdr:}"
|
||||||
--agent "$agent" --herdr-session "${HERDR_SESSION_NAME:-}" --watch-pid "$$")
|
--agent "$agent" --herdr-session "${HERDR_SESSION_NAME:-}" --watch-pid "$$")
|
||||||
[ -n "$tok" ] && args+=(--expect-dispatch-token "$tok")
|
[ -n "$tok" ] && args+=(--expect-dispatch-token "$tok")
|
||||||
@@ -737,11 +723,6 @@ stop_stall_watch() {
|
|||||||
STALL_PID=""
|
STALL_PID=""
|
||||||
}
|
}
|
||||||
|
|
||||||
_disk_status() {
|
|
||||||
local jid="$1"
|
|
||||||
"$PY" -c "import json, os; p=os.path.join('$REGISTRY_DIR', '$jid.json'); print(json.load(open(p)).get('status','')) if os.path.exists(p) else print('')" 2>/dev/null || true
|
|
||||||
}
|
|
||||||
|
|
||||||
# ISSUE-18: delivery is confirmed by the protocol, not the screen. After the
|
# ISSUE-18: delivery is confirmed by the protocol, not the screen. After the
|
||||||
# prompt was handed over (herdr accepted the submission, or the dispatch was
|
# prompt was handed over (herdr accepted the submission, or the dispatch was
|
||||||
# published for a remote session) the job's own `started` must arrive within
|
# published for a remote session) the job's own `started` must arrive within
|
||||||
@@ -816,208 +797,36 @@ _within_deadline() {
|
|||||||
wait "$pid"
|
wait "$pid"
|
||||||
}
|
}
|
||||||
|
|
||||||
# ISSUE-14: agent-agnostic liveness. publish_event appends every event to the
|
# finalize_unfinished <job_id> <status> <sub_rc> [phase] [session]
|
||||||
# job's on-disk events.log BEFORE its wire publish (record_local_event), so a
|
# Closes a job that did not complete (Issue #33 labels, ISSUE-14/18 disk grace,
|
||||||
# `progress` heartbeat the subscriber never saw (it had already idled out, or
|
# Issue #34 cancel reasons) and sets FINAL_JOB_STATUS. The decisions, the disk
|
||||||
# the broker dropped it) is visible here the moment it is sent - a slow or
|
# waits, the close and the event are `_decide finalize-begin/-end`; bash runs
|
||||||
# failing broker no longer delays it by its publish retries. An agent running
|
# only the herdr diagnostic probe between the two, when asked to.
|
||||||
# its tests in a background shell looks idle to herdr and shows no busy
|
|
||||||
# token, yet keeps heartbeating - this is the only evidence that covers that
|
|
||||||
# case.
|
|
||||||
_recent_disk_heartbeat() {
|
|
||||||
local job_id="$1" window="$2" f="$REGISTRY_DIR/$job_id.events.log"
|
|
||||||
[ -f "$f" ] || return 1
|
|
||||||
"$PY" - "$f" "$window" <<'PYEOF'
|
|
||||||
import calendar, json, sys, time
|
|
||||||
path, window = sys.argv[1], float(sys.argv[2])
|
|
||||||
last = ""
|
|
||||||
with open(path) as fh:
|
|
||||||
for line in fh:
|
|
||||||
if line.strip():
|
|
||||||
last = line
|
|
||||||
try:
|
|
||||||
stamp = json.loads(last)["timestamp"]
|
|
||||||
seen = calendar.timegm(time.strptime(stamp, "%Y-%m-%dT%H:%M:%SZ"))
|
|
||||||
except Exception:
|
|
||||||
sys.exit(1)
|
|
||||||
sys.exit(0 if time.time() - seen <= window else 1)
|
|
||||||
PYEOF
|
|
||||||
}
|
|
||||||
|
|
||||||
_map_sub_rc() {
|
|
||||||
local sub_rc="$1" job_id="$2"
|
|
||||||
local job_status="running"
|
|
||||||
if [[ $sub_rc -eq $SUB_RC_COMPLETED ]]; then
|
|
||||||
job_status="completed"
|
|
||||||
elif [[ $sub_rc -eq $SUB_RC_ERROR ]]; then
|
|
||||||
job_status="error"
|
|
||||||
elif [[ $sub_rc -eq $SUB_RC_BROKER_UNAVAILABLE ]]; then
|
|
||||||
job_status="broker_unavailable"
|
|
||||||
local disk_st
|
|
||||||
disk_st="$(_disk_status "$job_id")"
|
|
||||||
if [[ "$disk_st" == "completed" || "$disk_st" == "error" || "$disk_st" == "cancelled" ]]; then
|
|
||||||
job_status="$disk_st"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
job_status="timeout" # SUB_RC_NO_EVENT, SUB_RC_WALL_CLOCK (and any other code)
|
|
||||||
fi
|
|
||||||
echo "$job_status"
|
|
||||||
}
|
|
||||||
|
|
||||||
_rc_for() {
|
|
||||||
case "$1" in
|
|
||||||
completed) echo 0 ;;
|
|
||||||
error) echo 1 ;;
|
|
||||||
timeout) echo 2 ;;
|
|
||||||
broker_unavailable) echo 3 ;;
|
|
||||||
cancelled) echo 5 ;;
|
|
||||||
*) echo 1 ;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
finalize_unfinished() {
|
finalize_unfinished() {
|
||||||
local job_id="$1" job_status="$2" sub_rc="$3" phase="${4:-}" FINALIZE_CLOCK=""
|
local job_id="$1" job_status="$2" sub_rc="$3" phase="${4:-}"
|
||||||
if [ "${MAM_DELEGATE_FAIL_CLOSED:-1}" = "0" ]; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
if [ "$job_status" = "completed" ]; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
# Issue #30: what the stall watcher saw goes with an unfinished job's close.
|
|
||||||
if [ -s "$REGISTRY_DIR/$job_id.stall.out" ]; then
|
|
||||||
echo "stall watch log for $job_id (last 3 lines):" >&2
|
|
||||||
tail -n 3 "$REGISTRY_DIR/$job_id.stall.out" >&2 || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
local cur_disk_st="$(_disk_status "$job_id")"
|
|
||||||
if [ "$cur_disk_st" = "completed" ] || [ "$cur_disk_st" = "error" ] || [ "$cur_disk_st" = "cancelled" ]; then
|
|
||||||
FINAL_JOB_STATUS="$cur_disk_st"
|
|
||||||
[ "$cur_disk_st" = "completed" ] && return 0
|
|
||||||
else
|
|
||||||
FINAL_JOB_STATUS="$job_status"
|
|
||||||
fi
|
|
||||||
# broker_unavailable: re-observe the disk at 3 s intervals until this
|
|
||||||
# dispatch's wall-clock deadline (START_TIME + TIMEOUT). The deadline is read
|
|
||||||
# off the clock on every pass; summing the sleeps alone ignores the time the
|
|
||||||
# disk probes themselves take and overruns TIMEOUT.
|
|
||||||
if [ "$job_status" = "broker_unavailable" ]; then
|
|
||||||
local deadline left disk_st
|
|
||||||
deadline=$(( ${START_TIME:-$(date +%s)} + TIMEOUT ))
|
|
||||||
while :; do
|
|
||||||
left=$(( deadline - $(date +%s) ))
|
|
||||||
[ "$left" -le 0 ] && break
|
|
||||||
[ "$left" -gt 3 ] && left=3
|
|
||||||
sleep "$left"
|
|
||||||
disk_st="$(_disk_status "$job_id")"
|
|
||||||
if [ "$disk_st" = "completed" ] || [ "$disk_st" = "error" ] || [ "$disk_st" = "cancelled" ]; then
|
|
||||||
job_status="$disk_st"
|
|
||||||
FINAL_JOB_STATUS="$disk_st"
|
|
||||||
[ "$disk_st" = "completed" ] && return 0
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
# Issue #33 (review R3): a wait that ran into the deadline is the wall clock.
|
|
||||||
[ "$(date +%s)" -ge "$deadline" ] && FINALIZE_CLOCK="wall"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# timeout: if subscriber timed out, check if agent is still actively working in herdr
|
|
||||||
if [ "$job_status" = "timeout" ]; then
|
|
||||||
local target_sess="${5:-${current_session:-${target_session:-$AGENT_SESSION}}}"
|
local target_sess="${5:-${current_session:-${target_session:-$AGENT_SESSION}}}"
|
||||||
local sess="${target_sess#herdr:}"
|
local step final evidence_until deadline out
|
||||||
local target_agent
|
read -r step final evidence_until deadline <<<"$(_decide finalize-begin --job "$job_id" \
|
||||||
|
--registry-dir "$REGISTRY_DIR" --status "$job_status" ${START_TIME:+--start-time "$START_TIME"} \
|
||||||
|
--timeout "$TIMEOUT" --idle-timeout "$IDLE_TIMEOUT" --agent "$AGENT" --session "$target_sess")"
|
||||||
|
case "$step" in
|
||||||
|
keep) return 0 ;;
|
||||||
|
done) FINAL_JOB_STATUS="$final"; return 0 ;;
|
||||||
|
esac
|
||||||
|
FINAL_JOB_STATUS="$final"
|
||||||
|
if [ "$step" = "probe" ]; then
|
||||||
|
local sess="${target_sess#herdr:}" target_agent diag="no"
|
||||||
target_agent=$(_sanitize_herdr_agent_name "$sess")
|
target_agent=$(_sanitize_herdr_agent_name "$sess")
|
||||||
|
|
||||||
# ISSUE-14 / ISSUE-18: the subscriber's idle clock only counts events it
|
|
||||||
# received over the wire. Keep re-observing the disk while the *contract*
|
|
||||||
# says the agent is alive: an event in events.log at most IDLE_TIMEOUT
|
|
||||||
# (K x N) old (_recent_disk_heartbeat - publish_event records every event
|
|
||||||
# there before its wire publish, the automatic heartbeat timer included).
|
|
||||||
# herdr status and the pane are NOT evidence any more; they are logged
|
|
||||||
# once here as a diagnostic, so an operator can tell a dead agent from a
|
|
||||||
# silent one on the same host. Window policy: entering this grace opens
|
|
||||||
# one IDLE_TIMEOUT window and every disk event re-opens it from the time
|
|
||||||
# of that poll, so an agent that leaves no event at all is closed about
|
|
||||||
# 2 x IDLE_TIMEOUT after its last wire event. Evidence only ever extends
|
|
||||||
# the idle window, never this dispatch's wall clock: the loop ends at
|
|
||||||
# START_TIME + TIMEOUT read off the clock on every pass.
|
|
||||||
local deadline evidence_until left disk_st
|
|
||||||
deadline=$(( ${START_TIME:-$(date +%s)} + TIMEOUT ))
|
|
||||||
evidence_until=$(( $(date +%s) + IDLE_TIMEOUT ))
|
|
||||||
if [ "$AGENT" != "human" ] && [ "$sess" != "human" ] && [ -n "$sess" ] && ! _is_remote "$target_sess"; then
|
|
||||||
local diag="no"
|
|
||||||
_within_deadline "$deadline" _agent_still_working "$sess" "$target_agent" && diag="yes"
|
_within_deadline "$deadline" _agent_still_working "$sess" "$target_agent" && diag="yes"
|
||||||
echo "liveness: no wire event for ${IDLE_TIMEOUT}s on job $job_id; herdr/pane says busy=$diag (diagnostic only, not evidence)" >&2
|
echo "liveness: no wire event for ${IDLE_TIMEOUT}s on job $job_id; herdr/pane says busy=$diag (diagnostic only, not evidence)" >&2
|
||||||
fi
|
fi
|
||||||
while [ "$(date +%s)" -lt "$deadline" ]; do
|
# The answer comes back on fd 3: stdout carries registry.py's close output.
|
||||||
disk_st="$(_disk_status "$job_id")"
|
{ out="$(_decide finalize-end --job "$job_id" --registry-dir "$REGISTRY_DIR" --status "$job_status" \
|
||||||
if [ "$disk_st" = "completed" ] || [ "$disk_st" = "error" ] || [ "$disk_st" = "cancelled" ]; then
|
${START_TIME:+--start-time "$START_TIME"} --timeout "$TIMEOUT" --idle-timeout "$IDLE_TIMEOUT" \
|
||||||
job_status="$disk_st"
|
--sub-rc "$sub_rc" --phase "$phase" --type "$TYPE" --final "$final" --evidence-until "$evidence_until" \
|
||||||
FINAL_JOB_STATUS="$disk_st"
|
--dispatch-token "${_disp_tok:-}" --preflight-reason "${PREFLIGHT_REASON:-}" \
|
||||||
[ "$disk_st" = "completed" ] && return 0
|
--python "$PY" --scripts-dir "$SCRIPT_DIR/scripts" 3>&1 1>&4 4>&-)"; } 4>&1
|
||||||
break
|
FINAL_JOB_STATUS="$out"
|
||||||
fi
|
|
||||||
if _recent_disk_heartbeat "$job_id" "$IDLE_TIMEOUT"; then
|
|
||||||
evidence_until=$(( $(date +%s) + IDLE_TIMEOUT ))
|
|
||||||
elif [ "$(date +%s)" -ge "$evidence_until" ]; then
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
left=$(( deadline - $(date +%s) ))
|
|
||||||
[ "$left" -le 0 ] && break
|
|
||||||
sleep 1
|
|
||||||
done
|
|
||||||
[ "$(date +%s)" -ge "$deadline" ] && FINALIZE_CLOCK="wall"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Issue #33: the subscriber's rc says which clock ran out (4 wall clock,
|
|
||||||
# 2 no event); the disk-heartbeat grace above that ends on the deadline is
|
|
||||||
# the wall clock too. bash only carries the label.
|
|
||||||
local clock="${FINALIZE_CLOCK:-idle}"
|
|
||||||
[ "$sub_rc" = "$SUB_RC_WALL_CLOCK" ] && clock="wall"
|
|
||||||
local close_req="deadline_timeout"
|
|
||||||
[ "$clock" = "wall" ] && [ "$phase" != "inject" ] && close_req="wall_clock_timeout"
|
|
||||||
local detail=""
|
|
||||||
if [ "$phase" = "preflight" ] || [ "$phase" = "idle_wait" ]; then
|
|
||||||
close_req="cancelled"
|
|
||||||
detail="session_busy"
|
|
||||||
# Issue #34: a target the preflight could not see was never busy
|
|
||||||
[ "$phase" = "preflight" ] && [ -n "${PREFLIGHT_REASON:-}" ] && detail="$PREFLIGHT_REASON"
|
|
||||||
fi
|
|
||||||
|
|
||||||
[ "$close_req" != "cancelled" ] && echo "finalize: $job_id closed by $([ "$clock" = "wall" ] && echo wall_clock || echo no_event) clock (budget ${TIMEOUT}s / idle ${IDLE_TIMEOUT}s)" >&2
|
|
||||||
local close_args=(--job "$job_id" --request "$close_req")
|
|
||||||
[ -n "$detail" ] && close_args+=(--detail "$detail")
|
|
||||||
[ -n "${_disp_tok:-}" ] && close_args+=(--expect-dispatch-token "$_disp_tok")
|
|
||||||
|
|
||||||
local close_rc=0
|
|
||||||
"$PY" "$SCRIPT_DIR/scripts/registry.py" --registry-dir "$REGISTRY_DIR" close "${close_args[@]}" 2>&1 || close_rc=$?
|
|
||||||
|
|
||||||
# If close failed due to dispatch token mismatch (rc=4), suppress secondary event emission
|
|
||||||
# to prevent CAS/ABA bypass
|
|
||||||
if [ $close_rc -eq 4 ]; then
|
|
||||||
echo "finalize_unfinished: dispatch token mismatch (stale attempt); suppressing event emission" >&2
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check if job completed on disk (universal, for all TYPEs)
|
|
||||||
local cur_disk_st
|
|
||||||
cur_disk_st="$(_disk_status "$job_id")"
|
|
||||||
if [ "$cur_disk_st" = "completed" ]; then
|
|
||||||
FINAL_JOB_STATUS="completed"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# direct path only: publish MQTT error/cancelled event (loop/discuss forbidden: O-1)
|
|
||||||
if [ "$TYPE" = "direct" ]; then
|
|
||||||
local disp_arg=()
|
|
||||||
[ -n "${_disp_tok:-}" ] && disp_arg=(--expect-dispatch-token "$_disp_tok")
|
|
||||||
if [ "$close_req" = "cancelled" ]; then
|
|
||||||
"$PY" "$SCRIPT_DIR/scripts/publish_event.py" --registry-dir "$REGISTRY_DIR" --job "$job_id" --event cancelled --detail "$detail" ${disp_arg[@]+"${disp_arg[@]}"} 2>/dev/null || true
|
|
||||||
elif [ "$sub_rc" -ne "$SUB_RC_ERROR" ] || [ "$phase" = "inject" ]; then
|
|
||||||
local err_detail="$close_req"
|
|
||||||
[ "$phase" = "inject" ] && err_detail="agent_blocked"
|
|
||||||
"$PY" "$SCRIPT_DIR/scripts/publish_event.py" --registry-dir "$REGISTRY_DIR" --job "$job_id" --event error --detail "$err_detail" ${disp_arg[@]+"${disp_arg[@]}"} 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
}
|
}
|
||||||
|
|
||||||
preflight_agent() {
|
preflight_agent() {
|
||||||
@@ -1229,7 +1038,7 @@ cmd_wait() {
|
|||||||
fi
|
fi
|
||||||
# Issue #33: either clock is a timeout (rc 2) to callers of `wait`, as before;
|
# Issue #33: either clock is a timeout (rc 2) to callers of `wait`, as before;
|
||||||
# the subscriber's own log says which clock it was.
|
# the subscriber's own log says which clock it was.
|
||||||
[ "$rc" -eq "$SUB_RC_WALL_CLOCK" ] && rc=$SUB_RC_NO_EVENT
|
rc="$(_decide wait-rc "$rc")"
|
||||||
return "$rc"
|
return "$rc"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -197,7 +197,7 @@ orchestrator's disk, take part on equal terms.
|
|||||||
| Rule | Contract | Where |
|
| Rule | Contract | Where |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **Heartbeat obligation** | While working, the agent publishes `progress` at least every **N** s (`heartbeat_sec`, default 30). `publish_event.py --event started --heartbeat N` starts a detached timer that publishes `progress` (`detail: heartbeat`) every N s with the same `dispatch_token`/`attempt_id`, so the agent does not have to remember. No heartbeat can follow a terminal event: the terminal publish first raises an attempt-scoped barrier (`<registry>/<job>.heartbeat.barrier`, written under the registry lock that also allocates seqs, so a beat either took its seq before it or is refused), then stops the timer through `<registry>/<job>.heartbeat.pid` and waits for it to drain its in-flight beats (killing it after `MAM_HEARTBEAT_DRAIN_S`, default 1 s - a beat still on the wire is cancelled, never allowed to trail the terminal), and only then takes its own seq; replacing the timer (another `started --heartbeat` for the job) drains or kills the previous one the same way before it hands over the pidfile. Starts, replacements and stops of a job's timer are serialized by a per-job lifecycle lock (`<registry>/<job>.heartbeat.lock`, separate from the seq lock): a start that finds the attempt ended arms nothing, an exiting timer never waits for that lock, and a timer whose pid could not be recorded is killed at once. The timer also stops itself on a terminal record or barrier, on re-dispatch, after `timeout_sec`, and within one period of the agent process it watches going away (`--watch-pid`, default the nearest agent-CLI ancestor of the publishing command; a remote runner watches itself) - a crashed agent stops heartbeating, an alive-but-stuck one does not. | `lib_py/delegation/heartbeat.py`, `publish_event.py --heartbeat` |
|
| **Heartbeat obligation** | While working, the agent publishes `progress` at least every **N** s (`heartbeat_sec`, default 30). `publish_event.py --event started --heartbeat N` starts a detached timer that publishes `progress` (`detail: heartbeat`) every N s with the same `dispatch_token`/`attempt_id`, so the agent does not have to remember. No heartbeat can follow a terminal event: the terminal publish first raises an attempt-scoped barrier (`<registry>/<job>.heartbeat.barrier`, written under the registry lock that also allocates seqs, so a beat either took its seq before it or is refused), then stops the timer through `<registry>/<job>.heartbeat.pid` and waits for it to drain its in-flight beats (killing it after `MAM_HEARTBEAT_DRAIN_S`, default 1 s - a beat still on the wire is cancelled, never allowed to trail the terminal), and only then takes its own seq; replacing the timer (another `started --heartbeat` for the job) drains or kills the previous one the same way before it hands over the pidfile. Starts, replacements and stops of a job's timer are serialized by a per-job lifecycle lock (`<registry>/<job>.heartbeat.lock`, separate from the seq lock): a start that finds the attempt ended arms nothing, an exiting timer never waits for that lock, and a timer whose pid could not be recorded is killed at once. The timer also stops itself on a terminal record or barrier, on re-dispatch, after `timeout_sec`, and within one period of the agent process it watches going away (`--watch-pid`, default the nearest agent-CLI ancestor of the publishing command; a remote runner watches itself) - a crashed agent stops heartbeating, an alive-but-stuck one does not. | `lib_py/delegation/heartbeat.py`, `publish_event.py --heartbeat` |
|
||||||
| **Unresponsive** | The orchestrator treats a job as unresponsive after **K x N** s without any event (`heartbeat_misses`, default 4 → 120 s; `--idle-timeout` overrides). K x N is when silence is *detected*; the wrapper's post-idle grace below can stretch the final close to about 2 x K x N. The subscriber's idle clock counts every accepted event, and a retained state only when its seq is newer than every event and state already accepted - the same snapshot re-delivered on a reconnect, or an older one, is not liveness; the wrapper's post-idle grace re-reads only the job's events.log (`_recent_disk_heartbeat`, written before every wire publish). herdr status and the pane are logged once as a *diagnostic* (`liveness: ... herdr/pane says busy=yes/no (diagnostic only)`) and never decide. | `multi-agent-mux-delegate-job` (`finalize_unfinished`), `job_subscriber.py` |
|
| **Unresponsive** | The orchestrator treats a job as unresponsive after **K x N** s without any event (`heartbeat_misses`, default 4 → 120 s; `--idle-timeout` overrides). K x N is when silence is *detected*; the wrapper's post-idle grace below can stretch the final close to about 2 x K x N. The subscriber's idle clock counts every accepted event, and a retained state only when its seq is newer than every event and state already accepted - the same snapshot re-delivered on a reconnect, or an older one, is not liveness; the wrapper's post-idle grace re-reads only the job's events.log (`recent_disk_heartbeat`, written before every wire publish). herdr status and the pane are logged once as a *diagnostic* (`liveness: ... herdr/pane says busy=yes/no (diagnostic only)`) and never decide. | `multi-agent-mux-delegate-job` (`finalize_unfinished`, decided by `lib_py/delegation/wrapper.py`), `job_subscriber.py` |
|
||||||
| **Delivery** | After the prompt is handed over (herdr accepted the submission, or the remote dispatch was published), the agent's `started` must arrive within **T** s (`ack_timeout_sec`, default 180; a real agent reads the brief before it runs the start command): the registry leaves `pending`, a `started` line lands in events.log, or the subscriber prints one. No `started` within T → the inject fails closed: rc 1, registry `error` (`agent_blocked`), and the prompt is **never re-injected**. `send_keys_safe` confirms an accepted-but-unconfirmed submission only by `agent_status == working` or the job's `started`; pane changes and busy tokens are no longer signals. | `_wait_started`, `lib.sh: _sks_job_started` |
|
| **Delivery** | After the prompt is handed over (herdr accepted the submission, or the remote dispatch was published), the agent's `started` must arrive within **T** s (`ack_timeout_sec`, default 180; a real agent reads the brief before it runs the start command): the registry leaves `pending`, a `started` line lands in events.log, or the subscriber prints one. No `started` within T → the inject fails closed: rc 1, registry `error` (`agent_blocked`), and the prompt is **never re-injected**. `send_keys_safe` confirms an accepted-but-unconfirmed submission only by `agent_status == working` or the job's `started`; pane changes and busy tokens are no longer signals. | `_wait_started`, `lib.sh: _sks_job_started` |
|
||||||
| **Termination** | Only `completed` / `error` (and an operator `cancelled`) end a job. Terminal immutability (Issue #6 CAS) refuses anything that arrives after `deadline_timeout` closed the job. | `engine.py` |
|
| **Termination** | Only `completed` / `error` (and an operator `cancelled`) end a job. Terminal immutability (Issue #6 CAS) refuses anything that arrives after `deadline_timeout` closed the job. | `engine.py` |
|
||||||
| **Wire durability** | Every event (QoS 1) is followed, on the same connection, by a **retained** `<topic_prefix>/state` message (`event: state`, `status`, `last_event`, `seq`, signed like an event). A subscriber that connects or reconnects receives it at once: a terminal status ends the wait, a state whose seq is newer than every event and state already accepted resets the idle window (a re-delivered or older snapshot still recovers a terminal status but is not liveness), and a `seq` beyond the last one seen is recorded as `seq_gap` in the audit log (also for gaps between events). paho reconnects on its own; `on_connect` re-subscribes both topics and logs `reconnected`. | `publish_event.py: build_state_payload`, `job_subscriber.py: _on_state` |
|
| **Wire durability** | Every event (QoS 1) is followed, on the same connection, by a **retained** `<topic_prefix>/state` message (`event: state`, `status`, `last_event`, `seq`, signed like an event). A subscriber that connects or reconnects receives it at once: a terminal status ends the wait, a state whose seq is newer than every event and state already accepted resets the idle window (a re-delivered or older snapshot still recovers a terminal status but is not liveness), and a `seq` beyond the last one seen is recorded as `seq_gap` in the audit log (also for gaps between events). paho reconnects on its own; `on_connect` re-subscribes both topics and logs `reconnected`. | `publish_event.py: build_state_payload`, `job_subscriber.py: _on_state` |
|
||||||
|
|||||||
@@ -0,0 +1,264 @@
|
|||||||
|
# Issue #17 item 4: the decision functions as they were in bash at base 7209750
|
||||||
|
# (v4.8.5), copied verbatim, for tests/test_issue17_item4_decisions.py to run
|
||||||
|
# side by side with the new wrapper. Never sourced by the skills.
|
||||||
|
|
||||||
|
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() {
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
|
||||||
|
start_stall_watch() {
|
||||||
|
local session="$1" agent="$2" tok="${3:-}" att="${4:-}"
|
||||||
|
STALL_PID=""
|
||||||
|
[ "${MAM_STALL_WATCH:-1}" = "0" ] && return 0
|
||||||
|
if _is_remote "$session"; then
|
||||||
|
echo "stall watch: remote job, no local pane - stall detection off" >&2; return 0
|
||||||
|
fi
|
||||||
|
if [ "$agent" = "human" ] || [ "${session#herdr:}" = "human" ]; then
|
||||||
|
echo "stall watch: human job, no local pane - stall detection off" >&2; return 0
|
||||||
|
fi
|
||||||
|
local args=(--job "$JOB_ID" --registry-dir "$REGISTRY_DIR" --target "${session#herdr:}"
|
||||||
|
--agent "$agent" --herdr-session "${HERDR_SESSION_NAME:-}" --watch-pid "$$")
|
||||||
|
[ -n "$tok" ] && args+=(--expect-dispatch-token "$tok")
|
||||||
|
[ -n "$att" ] && args+=(--attempt-id "$att")
|
||||||
|
PYTHONPATH="$SCRIPT_DIR/..${PYTHONPATH:+:$PYTHONPATH}" "$PY" -m lib_py.stall_watch "${args[@]}" \
|
||||||
|
>"$REGISTRY_DIR/$JOB_ID.stall.out" 2>&1 &
|
||||||
|
STALL_PID=$!
|
||||||
|
echo "stall watch pid: $STALL_PID (log: $REGISTRY_DIR/$JOB_ID.stall.out)"
|
||||||
|
}
|
||||||
|
|
||||||
|
_disk_status() {
|
||||||
|
local jid="$1"
|
||||||
|
"$PY" -c "import json, os; p=os.path.join('$REGISTRY_DIR', '$jid.json'); print(json.load(open(p)).get('status','')) if os.path.exists(p) else print('')" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
_recent_disk_heartbeat() {
|
||||||
|
local job_id="$1" window="$2" f="$REGISTRY_DIR/$job_id.events.log"
|
||||||
|
[ -f "$f" ] || return 1
|
||||||
|
"$PY" - "$f" "$window" <<'PYEOF'
|
||||||
|
import calendar, json, sys, time
|
||||||
|
path, window = sys.argv[1], float(sys.argv[2])
|
||||||
|
last = ""
|
||||||
|
with open(path) as fh:
|
||||||
|
for line in fh:
|
||||||
|
if line.strip():
|
||||||
|
last = line
|
||||||
|
try:
|
||||||
|
stamp = json.loads(last)["timestamp"]
|
||||||
|
seen = calendar.timegm(time.strptime(stamp, "%Y-%m-%dT%H:%M:%SZ"))
|
||||||
|
except Exception:
|
||||||
|
sys.exit(1)
|
||||||
|
sys.exit(0 if time.time() - seen <= window else 1)
|
||||||
|
PYEOF
|
||||||
|
}
|
||||||
|
|
||||||
|
_map_sub_rc() {
|
||||||
|
local sub_rc="$1" job_id="$2"
|
||||||
|
local job_status="running"
|
||||||
|
if [[ $sub_rc -eq $SUB_RC_COMPLETED ]]; then
|
||||||
|
job_status="completed"
|
||||||
|
elif [[ $sub_rc -eq $SUB_RC_ERROR ]]; then
|
||||||
|
job_status="error"
|
||||||
|
elif [[ $sub_rc -eq $SUB_RC_BROKER_UNAVAILABLE ]]; then
|
||||||
|
job_status="broker_unavailable"
|
||||||
|
local disk_st
|
||||||
|
disk_st="$(_disk_status "$job_id")"
|
||||||
|
if [[ "$disk_st" == "completed" || "$disk_st" == "error" || "$disk_st" == "cancelled" ]]; then
|
||||||
|
job_status="$disk_st"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
job_status="timeout" # SUB_RC_NO_EVENT, SUB_RC_WALL_CLOCK (and any other code)
|
||||||
|
fi
|
||||||
|
echo "$job_status"
|
||||||
|
}
|
||||||
|
|
||||||
|
_rc_for() {
|
||||||
|
case "$1" in
|
||||||
|
completed) echo 0 ;;
|
||||||
|
error) echo 1 ;;
|
||||||
|
timeout) echo 2 ;;
|
||||||
|
broker_unavailable) echo 3 ;;
|
||||||
|
cancelled) echo 5 ;;
|
||||||
|
*) echo 1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
finalize_unfinished() {
|
||||||
|
local job_id="$1" job_status="$2" sub_rc="$3" phase="${4:-}" FINALIZE_CLOCK=""
|
||||||
|
if [ "${MAM_DELEGATE_FAIL_CLOSED:-1}" = "0" ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if [ "$job_status" = "completed" ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
# Issue #30: what the stall watcher saw goes with an unfinished job's close.
|
||||||
|
if [ -s "$REGISTRY_DIR/$job_id.stall.out" ]; then
|
||||||
|
echo "stall watch log for $job_id (last 3 lines):" >&2
|
||||||
|
tail -n 3 "$REGISTRY_DIR/$job_id.stall.out" >&2 || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
local cur_disk_st="$(_disk_status "$job_id")"
|
||||||
|
if [ "$cur_disk_st" = "completed" ] || [ "$cur_disk_st" = "error" ] || [ "$cur_disk_st" = "cancelled" ]; then
|
||||||
|
FINAL_JOB_STATUS="$cur_disk_st"
|
||||||
|
[ "$cur_disk_st" = "completed" ] && return 0
|
||||||
|
else
|
||||||
|
FINAL_JOB_STATUS="$job_status"
|
||||||
|
fi
|
||||||
|
# broker_unavailable: re-observe the disk at 3 s intervals until this
|
||||||
|
# dispatch's wall-clock deadline (START_TIME + TIMEOUT). The deadline is read
|
||||||
|
# off the clock on every pass; summing the sleeps alone ignores the time the
|
||||||
|
# disk probes themselves take and overruns TIMEOUT.
|
||||||
|
if [ "$job_status" = "broker_unavailable" ]; then
|
||||||
|
local deadline left disk_st
|
||||||
|
deadline=$(( ${START_TIME:-$(date +%s)} + TIMEOUT ))
|
||||||
|
while :; do
|
||||||
|
left=$(( deadline - $(date +%s) ))
|
||||||
|
[ "$left" -le 0 ] && break
|
||||||
|
[ "$left" -gt 3 ] && left=3
|
||||||
|
sleep "$left"
|
||||||
|
disk_st="$(_disk_status "$job_id")"
|
||||||
|
if [ "$disk_st" = "completed" ] || [ "$disk_st" = "error" ] || [ "$disk_st" = "cancelled" ]; then
|
||||||
|
job_status="$disk_st"
|
||||||
|
FINAL_JOB_STATUS="$disk_st"
|
||||||
|
[ "$disk_st" = "completed" ] && return 0
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
# Issue #33 (review R3): a wait that ran into the deadline is the wall clock.
|
||||||
|
[ "$(date +%s)" -ge "$deadline" ] && FINALIZE_CLOCK="wall"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# timeout: if subscriber timed out, check if agent is still actively working in herdr
|
||||||
|
if [ "$job_status" = "timeout" ]; then
|
||||||
|
local target_sess="${5:-${current_session:-${target_session:-$AGENT_SESSION}}}"
|
||||||
|
local sess="${target_sess#herdr:}"
|
||||||
|
local target_agent
|
||||||
|
target_agent=$(_sanitize_herdr_agent_name "$sess")
|
||||||
|
|
||||||
|
# ISSUE-14 / ISSUE-18: the subscriber's idle clock only counts events it
|
||||||
|
# received over the wire. Keep re-observing the disk while the *contract*
|
||||||
|
# says the agent is alive: an event in events.log at most IDLE_TIMEOUT
|
||||||
|
# (K x N) old (_recent_disk_heartbeat - publish_event records every event
|
||||||
|
# there before its wire publish, the automatic heartbeat timer included).
|
||||||
|
# herdr status and the pane are NOT evidence any more; they are logged
|
||||||
|
# once here as a diagnostic, so an operator can tell a dead agent from a
|
||||||
|
# silent one on the same host. Window policy: entering this grace opens
|
||||||
|
# one IDLE_TIMEOUT window and every disk event re-opens it from the time
|
||||||
|
# of that poll, so an agent that leaves no event at all is closed about
|
||||||
|
# 2 x IDLE_TIMEOUT after its last wire event. Evidence only ever extends
|
||||||
|
# the idle window, never this dispatch's wall clock: the loop ends at
|
||||||
|
# START_TIME + TIMEOUT read off the clock on every pass.
|
||||||
|
local deadline evidence_until left disk_st
|
||||||
|
deadline=$(( ${START_TIME:-$(date +%s)} + TIMEOUT ))
|
||||||
|
evidence_until=$(( $(date +%s) + IDLE_TIMEOUT ))
|
||||||
|
if [ "$AGENT" != "human" ] && [ "$sess" != "human" ] && [ -n "$sess" ] && ! _is_remote "$target_sess"; then
|
||||||
|
local diag="no"
|
||||||
|
_within_deadline "$deadline" _agent_still_working "$sess" "$target_agent" && diag="yes"
|
||||||
|
echo "liveness: no wire event for ${IDLE_TIMEOUT}s on job $job_id; herdr/pane says busy=$diag (diagnostic only, not evidence)" >&2
|
||||||
|
fi
|
||||||
|
while [ "$(date +%s)" -lt "$deadline" ]; do
|
||||||
|
disk_st="$(_disk_status "$job_id")"
|
||||||
|
if [ "$disk_st" = "completed" ] || [ "$disk_st" = "error" ] || [ "$disk_st" = "cancelled" ]; then
|
||||||
|
job_status="$disk_st"
|
||||||
|
FINAL_JOB_STATUS="$disk_st"
|
||||||
|
[ "$disk_st" = "completed" ] && return 0
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if _recent_disk_heartbeat "$job_id" "$IDLE_TIMEOUT"; then
|
||||||
|
evidence_until=$(( $(date +%s) + IDLE_TIMEOUT ))
|
||||||
|
elif [ "$(date +%s)" -ge "$evidence_until" ]; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
left=$(( deadline - $(date +%s) ))
|
||||||
|
[ "$left" -le 0 ] && break
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
[ "$(date +%s)" -ge "$deadline" ] && FINALIZE_CLOCK="wall"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Issue #33: the subscriber's rc says which clock ran out (4 wall clock,
|
||||||
|
# 2 no event); the disk-heartbeat grace above that ends on the deadline is
|
||||||
|
# the wall clock too. bash only carries the label.
|
||||||
|
local clock="${FINALIZE_CLOCK:-idle}"
|
||||||
|
[ "$sub_rc" = "$SUB_RC_WALL_CLOCK" ] && clock="wall"
|
||||||
|
local close_req="deadline_timeout"
|
||||||
|
[ "$clock" = "wall" ] && [ "$phase" != "inject" ] && close_req="wall_clock_timeout"
|
||||||
|
local detail=""
|
||||||
|
if [ "$phase" = "preflight" ] || [ "$phase" = "idle_wait" ]; then
|
||||||
|
close_req="cancelled"
|
||||||
|
detail="session_busy"
|
||||||
|
# Issue #34: a target the preflight could not see was never busy
|
||||||
|
[ "$phase" = "preflight" ] && [ -n "${PREFLIGHT_REASON:-}" ] && detail="$PREFLIGHT_REASON"
|
||||||
|
fi
|
||||||
|
|
||||||
|
[ "$close_req" != "cancelled" ] && echo "finalize: $job_id closed by $([ "$clock" = "wall" ] && echo wall_clock || echo no_event) clock (budget ${TIMEOUT}s / idle ${IDLE_TIMEOUT}s)" >&2
|
||||||
|
local close_args=(--job "$job_id" --request "$close_req")
|
||||||
|
[ -n "$detail" ] && close_args+=(--detail "$detail")
|
||||||
|
[ -n "${_disp_tok:-}" ] && close_args+=(--expect-dispatch-token "$_disp_tok")
|
||||||
|
|
||||||
|
local close_rc=0
|
||||||
|
"$PY" "$SCRIPT_DIR/scripts/registry.py" --registry-dir "$REGISTRY_DIR" close "${close_args[@]}" 2>&1 || close_rc=$?
|
||||||
|
|
||||||
|
# If close failed due to dispatch token mismatch (rc=4), suppress secondary event emission
|
||||||
|
# to prevent CAS/ABA bypass
|
||||||
|
if [ $close_rc -eq 4 ]; then
|
||||||
|
echo "finalize_unfinished: dispatch token mismatch (stale attempt); suppressing event emission" >&2
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if job completed on disk (universal, for all TYPEs)
|
||||||
|
local cur_disk_st
|
||||||
|
cur_disk_st="$(_disk_status "$job_id")"
|
||||||
|
if [ "$cur_disk_st" = "completed" ]; then
|
||||||
|
FINAL_JOB_STATUS="completed"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# direct path only: publish MQTT error/cancelled event (loop/discuss forbidden: O-1)
|
||||||
|
if [ "$TYPE" = "direct" ]; then
|
||||||
|
local disp_arg=()
|
||||||
|
[ -n "${_disp_tok:-}" ] && disp_arg=(--expect-dispatch-token "$_disp_tok")
|
||||||
|
if [ "$close_req" = "cancelled" ]; then
|
||||||
|
"$PY" "$SCRIPT_DIR/scripts/publish_event.py" --registry-dir "$REGISTRY_DIR" --job "$job_id" --event cancelled --detail "$detail" ${disp_arg[@]+"${disp_arg[@]}"} 2>/dev/null || true
|
||||||
|
elif [ "$sub_rc" -ne "$SUB_RC_ERROR" ] || [ "$phase" = "inject" ]; then
|
||||||
|
local err_detail="$close_req"
|
||||||
|
[ "$phase" = "inject" ] && err_detail="agent_blocked"
|
||||||
|
"$PY" "$SCRIPT_DIR/scripts/publish_event.py" --registry-dir "$REGISTRY_DIR" --job "$job_id" --event error --detail "$err_detail" ${disp_arg[@]+"${disp_arg[@]}"} 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_wait() {
|
||||||
|
parse_opts "$@"
|
||||||
|
PY="$(pick_python)"
|
||||||
|
local rc=0
|
||||||
|
if [[ -n "$JOB_ID" ]]; then
|
||||||
|
"$PY" "$SCRIPT_DIR/scripts/job_subscriber.py" --registry-dir "$REGISTRY_DIR" \
|
||||||
|
--job "$JOB_ID" --timeout "$TIMEOUT" || rc=$?
|
||||||
|
else
|
||||||
|
"$PY" "$SCRIPT_DIR/scripts/job_subscriber.py" --registry-dir "$REGISTRY_DIR" \
|
||||||
|
--wait-any --timeout "$TIMEOUT" || rc=$?
|
||||||
|
fi
|
||||||
|
# Issue #33: either clock is a timeout (rc 2) to callers of `wait`, as before;
|
||||||
|
# the subscriber's own log says which clock it was.
|
||||||
|
[ "$rc" -eq "$SUB_RC_WALL_CLOCK" ] && rc=$SUB_RC_NO_EVENT
|
||||||
|
return "$rc"
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,424 @@
|
|||||||
|
"""Issue #17 item 4: the delegate-job wrapper's decisions moved from bash to
|
||||||
|
Python (lib_py/delegation/wrapper.py, lib_py/ints.py) with no behaviour change.
|
||||||
|
|
||||||
|
- U: unit tables for the new Python code (every input -> label / exit code,
|
||||||
|
invalid input, boundaries).
|
||||||
|
- C: comparison - the same input through the old bash functions (base
|
||||||
|
7209750, tests/data/issue17_item4_old_decisions.sh) and through the new
|
||||||
|
wrapper gives the same output, the same FINAL_JOB_STATUS and the same
|
||||||
|
registry.py / publish_event.py calls.
|
||||||
|
- S: static guard - the wrapper no longer carries the moved decisions.
|
||||||
|
Runs on macOS and Linux: no /proc, no broker (registry.py / publish_event.py
|
||||||
|
are stand-ins that log their argv).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
SKILLS = REPO_ROOT / ".agents" / "skills"
|
||||||
|
WRAPPER = SKILLS / "multi-agent-mux-delegate-job" / "multi-agent-mux-delegate-job"
|
||||||
|
LIB_INT = SKILLS / "lib_int.sh"
|
||||||
|
INTS_PY = SKILLS / "lib_py" / "ints.py"
|
||||||
|
OLD = REPO_ROOT / "tests" / "data" / "issue17_item4_old_decisions.sh"
|
||||||
|
|
||||||
|
if str(SKILLS) not in sys.path:
|
||||||
|
sys.path.insert(0, str(SKILLS))
|
||||||
|
|
||||||
|
from lib_py import ints # noqa: E402
|
||||||
|
from lib_py.delegation import wrapper as decide # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# U - unit tables
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_u_subscriber_codes_mirror_job_subscriber():
|
||||||
|
from lib_py.delegation import job_subscriber
|
||||||
|
assert (decide.SUB_RC_COMPLETED, decide.SUB_RC_ERROR, decide.SUB_RC_NO_EVENT, decide.SUB_RC_BROKER_UNAVAILABLE,
|
||||||
|
decide.SUB_RC_WALL_CLOCK) == (job_subscriber.RC_COMPLETED, job_subscriber.RC_ERROR, job_subscriber.RC_NO_EVENT,
|
||||||
|
job_subscriber.RC_BROKER_UNAVAILABLE, job_subscriber.RC_WALL_CLOCK)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("disk", [None, "", "pending", "running", "completed", "error", "cancelled", "garbage"])
|
||||||
|
@pytest.mark.parametrize("rc,want", [(0, "completed"), (1, "error"), (2, "timeout"), (3, None), (4, "timeout"),
|
||||||
|
(5, "timeout"), (124, "timeout"), (-1, "timeout")])
|
||||||
|
def test_u_sub_status_table(tmp_path, rc, want, disk):
|
||||||
|
if disk == "garbage":
|
||||||
|
(tmp_path / "j.json").write_text("{not json")
|
||||||
|
elif disk is not None:
|
||||||
|
(tmp_path / "j.json").write_text(json.dumps({"status": disk}))
|
||||||
|
if want is None: # broker unavailable: a terminal record on disk wins
|
||||||
|
want = disk if disk in ("completed", "error", "cancelled") else "broker_unavailable"
|
||||||
|
assert decide.sub_status(rc, str(tmp_path), "j") == want
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("rc,up", [(0, True), (3, True), (1, False), (2, False), (4, False), (143, False)])
|
||||||
|
def test_u_subscriber_up(rc, up):
|
||||||
|
assert decide.subscriber_up(rc) is up
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("fc", [None, "", "1", "0", "yes"])
|
||||||
|
@pytest.mark.parametrize("status,code", [("completed", 0), ("error", 1), ("timeout", 2), ("broker_unavailable", 3),
|
||||||
|
("cancelled", 5), ("running", 1), ("", 1), ("pending", 1)])
|
||||||
|
def test_u_exit_code_table(monkeypatch, status, code, fc):
|
||||||
|
if fc is None:
|
||||||
|
monkeypatch.delenv("MAM_DELEGATE_FAIL_CLOSED", raising=False)
|
||||||
|
else:
|
||||||
|
monkeypatch.setenv("MAM_DELEGATE_FAIL_CLOSED", fc)
|
||||||
|
assert decide.exit_code(status) == (0 if fc == "0" else code)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("rc,want", [(0, 0), (1, 1), (2, 2), (3, 3), (4, 2), (5, 5)])
|
||||||
|
def test_u_wait_rc(rc, want):
|
||||||
|
assert decide.wait_rc(rc) == want
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("env,session,agent,watch,msg", [
|
||||||
|
(None, "herdr:c", "claude", True, ""),
|
||||||
|
("", "herdr:c", "claude", True, ""),
|
||||||
|
("1", "c", "codex", True, ""),
|
||||||
|
("0", "herdr:c", "claude", False, ""),
|
||||||
|
("0", "remote:r", "claude", False, ""),
|
||||||
|
(None, "remote:r", "claude", False, "remote job"),
|
||||||
|
(None, "remote:human", "human", False, "remote job"),
|
||||||
|
(None, "herdr:c", "human", False, "human job"),
|
||||||
|
(None, "herdr:human", "claude", False, "human job"),
|
||||||
|
(None, "human", "claude", False, "human job"),
|
||||||
|
(None, "herdr:herdr:human", "claude", True, ""), # ${session#herdr:} strips one prefix
|
||||||
|
])
|
||||||
|
def test_u_stall_watch_table(monkeypatch, capsys, env, session, agent, watch, msg):
|
||||||
|
if env is None:
|
||||||
|
monkeypatch.delenv("MAM_STALL_WATCH", raising=False)
|
||||||
|
else:
|
||||||
|
monkeypatch.setenv("MAM_STALL_WATCH", env)
|
||||||
|
assert decide.stall_watch(session, agent) is watch
|
||||||
|
err = capsys.readouterr().err
|
||||||
|
assert (msg in err) if msg else err == ""
|
||||||
|
|
||||||
|
|
||||||
|
def _events(reg: Path, jid: str, *stamps: str) -> None:
|
||||||
|
with open(reg / f"{jid}.events.log", "w") as fh:
|
||||||
|
for s in stamps:
|
||||||
|
fh.write((json.dumps({"event": "progress", "timestamp": s}) if s else "") + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
def test_u_recent_disk_heartbeat(tmp_path):
|
||||||
|
now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||||
|
old = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(time.time() - 100))
|
||||||
|
assert decide.recent_disk_heartbeat(str(tmp_path), "j", 10) is False # no file
|
||||||
|
_events(tmp_path, "j", old, now, "")
|
||||||
|
assert decide.recent_disk_heartbeat(str(tmp_path), "j", 10) is True # the last non-blank line
|
||||||
|
_events(tmp_path, "j", now, old)
|
||||||
|
assert decide.recent_disk_heartbeat(str(tmp_path), "j", 10) is False
|
||||||
|
assert decide.recent_disk_heartbeat(str(tmp_path), "j", 200) is True
|
||||||
|
for bad in ("{not json", json.dumps({"event": "x"}), json.dumps({"timestamp": "yesterday"})):
|
||||||
|
(tmp_path / "j.events.log").write_text(bad + "\n")
|
||||||
|
assert decide.recent_disk_heartbeat(str(tmp_path), "j", 10**9) is False
|
||||||
|
|
||||||
|
|
||||||
|
def _ns(**kw):
|
||||||
|
import argparse
|
||||||
|
base = dict(job="j", registry_dir="", status="timeout", start_time=None, timeout=60, idle_timeout=20,
|
||||||
|
agent="claude", session="herdr:c")
|
||||||
|
base.update(kw)
|
||||||
|
return argparse.Namespace(**base)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("status,session,agent,step", [
|
||||||
|
("timeout", "herdr:c", "claude", "probe"), ("timeout", "c", "codex", "probe"),
|
||||||
|
("timeout", "remote:c", "claude", "go"), ("timeout", "herdr:human", "claude", "go"),
|
||||||
|
("timeout", "herdr:c", "human", "go"), ("timeout", "", "claude", "go"), ("timeout", "herdr:", "claude", "go"),
|
||||||
|
("error", "herdr:c", "claude", "go"), ("broker_unavailable", "herdr:c", "claude", "go"),
|
||||||
|
("cancelled", "herdr:c", "claude", "go"),
|
||||||
|
])
|
||||||
|
def test_u_finalize_begin_steps(tmp_path, monkeypatch, status, session, agent, step):
|
||||||
|
monkeypatch.delenv("MAM_DELEGATE_FAIL_CLOSED", raising=False)
|
||||||
|
out = decide.finalize_begin(_ns(registry_dir=str(tmp_path), status=status, session=session, agent=agent,
|
||||||
|
start_time=1000, timeout=60, idle_timeout=20)).split()
|
||||||
|
assert out[0] == step and out[1] == status and out[3] == "1060"
|
||||||
|
assert abs(int(out[2]) - (int(time.time()) + 20)) <= 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_u_finalize_begin_keep_done_and_disk(tmp_path, monkeypatch, capsys):
|
||||||
|
monkeypatch.delenv("MAM_DELEGATE_FAIL_CLOSED", raising=False)
|
||||||
|
reg = str(tmp_path)
|
||||||
|
assert decide.finalize_begin(_ns(registry_dir=reg, status="completed")) == "keep"
|
||||||
|
monkeypatch.setenv("MAM_DELEGATE_FAIL_CLOSED", "0")
|
||||||
|
assert decide.finalize_begin(_ns(registry_dir=reg, status="error")) == "keep"
|
||||||
|
monkeypatch.setenv("MAM_DELEGATE_FAIL_CLOSED", "1")
|
||||||
|
(tmp_path / "j.json").write_text(json.dumps({"status": "completed"}))
|
||||||
|
assert decide.finalize_begin(_ns(registry_dir=reg, status="timeout")) == "done completed"
|
||||||
|
for disk in ("error", "cancelled"):
|
||||||
|
(tmp_path / "j.json").write_text(json.dumps({"status": disk}))
|
||||||
|
assert decide.finalize_begin(_ns(registry_dir=reg, status="timeout")).split()[:2] == ["probe", disk]
|
||||||
|
(tmp_path / "j.json").write_text(json.dumps({"status": "running"}))
|
||||||
|
(tmp_path / "j.stall.out").write_text("a\nb\nc\nd\n")
|
||||||
|
capsys.readouterr()
|
||||||
|
assert decide.finalize_begin(_ns(registry_dir=reg, status="error")).split()[:2] == ["go", "error"]
|
||||||
|
assert capsys.readouterr().err == "stall watch log for j (last 3 lines):\nb\nc\nd\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_u_cli_refuses_bad_input(tmp_path):
|
||||||
|
env = {**os.environ, "PYTHONPATH": str(SKILLS)}
|
||||||
|
for argv in (["sub-status", "x", "--job", "j", "--registry-dir", "."], ["sub-status", "1"], ["exit-code"],
|
||||||
|
["wait-rc", "1.5"], ["nope"], [], ["finalize-end", "--job", "j"]):
|
||||||
|
res = subprocess.run([sys.executable, "-m", "lib_py.delegation.wrapper", *argv], capture_output=True,
|
||||||
|
text=True, env=env, timeout=60)
|
||||||
|
assert res.returncode == 2 and res.stdout == "", (argv, res.stdout, res.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("value,minimum,want", [
|
||||||
|
("999999999", 1, 999999999), ("1000000000", 1, None), ("0999999999", 1, 999999999), ("1", 1, 1),
|
||||||
|
("0", 1, None), ("0", 0, 0), ("00", 0, 0), (" 7\n", 7, 7), ("6", 7, None), ("+5", 0, None), ("5 5", 0, None),
|
||||||
|
("٥", 0, None), ("", 0, None), (None, 0, None)])
|
||||||
|
def test_u_ints_boundaries(value, minimum, want):
|
||||||
|
assert ints.positive_int(value, minimum) == want
|
||||||
|
|
||||||
|
|
||||||
|
def test_u_ints_cli(tmp_path):
|
||||||
|
def run(*a):
|
||||||
|
return subprocess.run([sys.executable, "-I", str(INTS_PY), *a], capture_output=True, text=True, timeout=60)
|
||||||
|
assert run("int", "0700", "1").stdout == "700\n"
|
||||||
|
assert run("int", "abc", "1").returncode == 1
|
||||||
|
res = run("knob", "K", "abc", "30", "1")
|
||||||
|
assert res.stdout == "30\n" and "WARNING: K=abc ignored (not a decimal integer >= 1); using 30" in res.stderr
|
||||||
|
assert run("knob", "K", "", "$X", "0").stdout == "$X\n" # the default is printed as given
|
||||||
|
for bad in ([], ["int", "1"], ["knob", "K", "1", "2"], ["other", "1", "2"]):
|
||||||
|
assert run(*bad).returncode == 2, bad
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# C - old bash vs new wrapper, same input, same result
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _shim(tmp_path: Path) -> Path:
|
||||||
|
"""The wrapper's $PY: registry.py / publish_event.py / job_subscriber.py and
|
||||||
|
the stall watcher are stand-ins that log their argv; anything else is the
|
||||||
|
real interpreter."""
|
||||||
|
shim = tmp_path / "py-shim"
|
||||||
|
shim.write_text(
|
||||||
|
"#!/bin/sh\n"
|
||||||
|
'case "$1" in\n'
|
||||||
|
' */scripts/registry.py) shift; echo "registry.py $*" >>"$CALL_LOG"; echo close-out; echo close-err >&2; '
|
||||||
|
'exit "${CLOSE_RC:-0}";;\n'
|
||||||
|
' */scripts/publish_event.py) shift; echo "publish_event.py $*" >>"$CALL_LOG"; echo pub-out; '
|
||||||
|
'echo pub-err >&2; exit 0;;\n'
|
||||||
|
' */scripts/job_subscriber.py) shift; echo "job_subscriber.py $*" >>"$CALL_LOG"; exit "${SUB_RC:-0}";;\n'
|
||||||
|
"esac\n"
|
||||||
|
'case "$*" in *lib_py.stall_watch*) echo "stall_watch $*" >>"$CALL_LOG"; exit 0;; esac\n'
|
||||||
|
f'exec "{sys.executable}" "$@"\n')
|
||||||
|
shim.chmod(0o755)
|
||||||
|
return shim
|
||||||
|
|
||||||
|
|
||||||
|
def _env(tmp_path: Path, **extra) -> dict:
|
||||||
|
env = {k: v for k, v in os.environ.items()
|
||||||
|
if not k.startswith(("HERDR_", "MAM_", "SKS_")) and k not in ("PYTHONPATH",)}
|
||||||
|
env.update(MAM_DELEGATE_JOB_NO_MAIN="1", MAM_ENV_FILE=str(tmp_path / ".mam.env.absent"), HOME=str(tmp_path),
|
||||||
|
DELEGATE_JOB_LOGS_DIR=str(tmp_path / "logs"))
|
||||||
|
env.update({k: str(v) for k, v in extra.items() if v is not None})
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
def _run_both(tmp_path: Path, body: str, env_extra: dict, setup=None):
|
||||||
|
"""Run `body` after sourcing the new wrapper, once as is and once with the
|
||||||
|
old functions sourced over it. Returns [(rc, stdout, stderr, calls)] old, new."""
|
||||||
|
out = []
|
||||||
|
for which in ("old", "new"):
|
||||||
|
work = tmp_path / which
|
||||||
|
reg = work / "reg"
|
||||||
|
reg.mkdir(parents=True)
|
||||||
|
if setup:
|
||||||
|
setup(reg)
|
||||||
|
log = work / "calls.log"
|
||||||
|
log.write_text("")
|
||||||
|
shim = _shim(work)
|
||||||
|
script = (f'source "{WRAPPER}"\n' + (f'source "{OLD}"\n' if which == "old" else "")
|
||||||
|
+ f'REGISTRY_DIR="{reg}"; PY="{shim}"\n' + body)
|
||||||
|
res = subprocess.run(["bash", "-c", script], capture_output=True, text=True, cwd=str(work), timeout=120,
|
||||||
|
env=_env(work, CALL_LOG=log, DELEGATE_JOB_PYTHON=shim, **env_extra))
|
||||||
|
norm = lambda t: t.replace(str(work), "<W>") # noqa: E731
|
||||||
|
calls = re.sub(r"--watch-pid \d+", "--watch-pid <pid>", norm(log.read_text()))
|
||||||
|
out.append((res.returncode, norm(res.stdout), norm(res.stderr), calls))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _finalize_body(status, sub_rc, phase, *, typ="direct", agent="claude", session="herdr:c", timeout=5, idle=0,
|
||||||
|
start_offset=-100, tok="tok1", reason="", fifth=None):
|
||||||
|
return ("_agent_still_working() { return 1; }\n"
|
||||||
|
"_sanitize_herdr_agent_name() { printf '%s\\n' \"$1\"; }\n"
|
||||||
|
f'TYPE="{typ}"; AGENT="{agent}"; AGENT_SESSION="{session}"; TIMEOUT={timeout}; IDLE_TIMEOUT={idle}\n'
|
||||||
|
f'START_TIME=$(( $(date +%s) + {start_offset} )); _disp_tok="{tok}"; PREFLIGHT_REASON="{reason}"\n'
|
||||||
|
'FINAL_JOB_STATUS="unset"\n'
|
||||||
|
f'finalize_unfinished j1 "{status}" "{sub_rc}" "{phase}"' + (f' "{fifth}"' if fifth else "") + "\n"
|
||||||
|
'echo "FINAL=$FINAL_JOB_STATUS"\n')
|
||||||
|
|
||||||
|
|
||||||
|
def _disk(status):
|
||||||
|
def setup(reg):
|
||||||
|
if status is not None:
|
||||||
|
(reg / "j1.json").write_text(json.dumps({"job_id": "j1", "status": status}))
|
||||||
|
return setup
|
||||||
|
|
||||||
|
|
||||||
|
PAIRS = [("error", 1), ("timeout", 2), ("timeout", 4), ("broker_unavailable", 3), ("completed", 0), ("error", 2)]
|
||||||
|
PHASES = ["direct", "inject", "preflight", "idle_wait", "subscriber", "worker"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("phase", PHASES)
|
||||||
|
@pytest.mark.parametrize("status,sub_rc", PAIRS)
|
||||||
|
def test_c_finalize_labels_closes_and_events(tmp_path, status, sub_rc, phase):
|
||||||
|
"""Past the wall clock: every status x subscriber rc x phase."""
|
||||||
|
old, new = _run_both(tmp_path, _finalize_body(status, sub_rc, phase, reason="target_not_found"), {},
|
||||||
|
setup=_disk("running"))
|
||||||
|
assert old == new, (old, new)
|
||||||
|
assert "FINAL=" in new[1]
|
||||||
|
|
||||||
|
|
||||||
|
VARIANTS = [
|
||||||
|
# (status, sub_rc, phase, body kwargs, disk, env)
|
||||||
|
("timeout", 2, "direct", dict(start_offset=0, timeout=60, idle=0), "running", {}), # idle clock
|
||||||
|
("timeout", 2, "direct", dict(start_offset=0, timeout=60, idle=0, agent="human"), "running", {}),
|
||||||
|
("timeout", 2, "direct", dict(session="remote:r"), "running", {}),
|
||||||
|
("timeout", 2, "worker", dict(fifth="herdr:other"), "running", {}),
|
||||||
|
("timeout", 2, "direct", dict(typ="loop"), "running", {}),
|
||||||
|
("error", 1, "inject", dict(typ="discuss"), "running", {}),
|
||||||
|
("timeout", 2, "direct", {}, "completed", {}),
|
||||||
|
("timeout", 2, "direct", {}, "error", {}),
|
||||||
|
("broker_unavailable", 3, "direct", {}, "cancelled", {}),
|
||||||
|
("broker_unavailable", 3, "direct", {}, None, {}),
|
||||||
|
("error", 1, "direct", {}, "garbage", {}),
|
||||||
|
("timeout", 4, "direct", dict(tok=""), "running", {}),
|
||||||
|
("error", 1, "preflight", dict(reason=""), "running", {}),
|
||||||
|
("error", 1, "preflight", dict(reason="target_not_found", tok=""), "running", {}),
|
||||||
|
("error", 2, "idle_wait", dict(reason="target_not_found"), "running", {}),
|
||||||
|
("timeout", 2, "direct", {}, "running", {"CLOSE_RC": "4"}),
|
||||||
|
("error", 1, "inject", {}, "running", {"CLOSE_RC": "1"}),
|
||||||
|
("error", 1, "direct", {}, "running", {"MAM_DELEGATE_FAIL_CLOSED": "0"}),
|
||||||
|
("completed", 0, "direct", {}, "error", {}),
|
||||||
|
("broker_unavailable", 3, "direct", dict(start_offset=0, timeout=1), "running", {}), # one 1 s re-observation
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("status,sub_rc,phase,kw,disk,env", VARIANTS)
|
||||||
|
def test_c_finalize_variants(tmp_path, status, sub_rc, phase, kw, disk, env):
|
||||||
|
def setup(reg):
|
||||||
|
if disk == "garbage":
|
||||||
|
(reg / "j1.json").write_text("{not json")
|
||||||
|
else:
|
||||||
|
_disk(disk)(reg)
|
||||||
|
(reg / "j1.stall.out").write_text("one\ntwo\nthree\nfour\n")
|
||||||
|
old, new = _run_both(tmp_path, _finalize_body(status, sub_rc, phase, **kw), env, setup=setup)
|
||||||
|
assert old == new, (old, new)
|
||||||
|
|
||||||
|
|
||||||
|
def test_c_finalize_heartbeat_grace_runs_to_the_wall_clock(tmp_path):
|
||||||
|
"""Fresh disk heartbeats keep the grace open until the deadline (2 s)."""
|
||||||
|
def setup(reg):
|
||||||
|
_disk("running")(reg)
|
||||||
|
_events(reg, "j1", time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(time.time() + 30)))
|
||||||
|
t0 = time.time()
|
||||||
|
old, new = _run_both(tmp_path, _finalize_body("timeout", 2, "direct", start_offset=0, timeout=2, idle=1), {},
|
||||||
|
setup=setup)
|
||||||
|
assert old == new, (old, new)
|
||||||
|
assert "wall_clock_timeout" in new[3] and time.time() - t0 >= 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_c_finalize_grace_sees_a_late_completion(tmp_path):
|
||||||
|
"""A record that turns completed during the grace ends it as completed."""
|
||||||
|
body = ("( sleep 1; printf '%s' '{\"status\": \"completed\"}' > \"$REGISTRY_DIR/j1.json\" ) &\n"
|
||||||
|
+ _finalize_body("timeout", 2, "direct", start_offset=0, timeout=30, idle=10))
|
||||||
|
old, new = _run_both(tmp_path, body, {}, setup=_disk("running"))
|
||||||
|
assert old == new, (old, new)
|
||||||
|
assert "FINAL=completed" in new[1] and new[3] == ""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("rc", [0, 1, 2, 3, 4, 5])
|
||||||
|
def test_c_sub_status_exit_code_and_subscriber_up(tmp_path, rc):
|
||||||
|
body = (f'st=$(_map_sub_rc {rc} j1 2>/dev/null || _decide sub-status {rc} --job j1 --registry-dir "$REGISTRY_DIR")\n'
|
||||||
|
'echo "status=$st"\n')
|
||||||
|
# the old file defines _map_sub_rc; the new wrapper does not
|
||||||
|
for disk in (None, "running", "completed", "error", "cancelled"):
|
||||||
|
old, new = _run_both(tmp_path / str(disk), body, {}, setup=_disk(disk))
|
||||||
|
assert old == new, (disk, old, new)
|
||||||
|
for fc in ("1", "0"):
|
||||||
|
code = (f'if declare -F _rc_for >/dev/null; then if [ "${{MAM_DELEGATE_FAIL_CLOSED:-1}}" = "0" ]; then echo 0; '
|
||||||
|
f'else _rc_for "$(_map_sub_rc {rc} j1)"; fi; else _decide exit-code "$(_decide sub-status {rc} --job j1 '
|
||||||
|
f'--registry-dir "$REGISTRY_DIR")"; fi\n')
|
||||||
|
old, new = _run_both(tmp_path / f"fc{fc}", code, {"MAM_DELEGATE_FAIL_CLOSED": fc})
|
||||||
|
assert old == new, (fc, old, new)
|
||||||
|
up = (f'if declare -F _rc_for >/dev/null; then sub_exit={rc}; '
|
||||||
|
f'if [ $sub_exit -eq $SUB_RC_COMPLETED ] || [ $sub_exit -eq $SUB_RC_BROKER_UNAVAILABLE ]; then echo up; '
|
||||||
|
f'else echo down; fi; else _decide subscriber-up {rc} && echo up || echo down; fi\n')
|
||||||
|
old, new = _run_both(tmp_path / "up", up, {})
|
||||||
|
assert old == new, (old, new)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("rc", [0, 1, 2, 3, 4, 5])
|
||||||
|
@pytest.mark.parametrize("job", ["", "j1"])
|
||||||
|
def test_c_cmd_wait(tmp_path, rc, job):
|
||||||
|
body = (f'set +e; cmd_wait {"--job " + job if job else ""} --timeout 5; echo "rc=$?"\n')
|
||||||
|
old, new = _run_both(tmp_path, body, {"SUB_RC": rc})
|
||||||
|
assert old == new, (old, new)
|
||||||
|
assert f"rc={2 if rc == 4 else rc}" in new[1]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("env,session,agent", [
|
||||||
|
(None, "herdr:c", "claude"), ("0", "herdr:c", "claude"), (None, "remote:r", "claude"),
|
||||||
|
(None, "herdr:c", "human"), (None, "herdr:human", "codex"), ("", "c", "codex")])
|
||||||
|
@pytest.mark.parametrize("tok,att", [("", ""), ("tok1", "2")])
|
||||||
|
def test_c_start_stall_watch(tmp_path, env, session, agent, tok, att):
|
||||||
|
body = (f'JOB_ID=j1; start_stall_watch "{session}" "{agent}" "{tok}" "{att}" >out.txt\n'
|
||||||
|
'[ -z "$STALL_PID" ] || wait "$STALL_PID"\n'
|
||||||
|
'sed "s/pid: [0-9]*/pid: N/" out.txt; echo "spawned=${STALL_PID:+yes}"\n')
|
||||||
|
old, new = _run_both(tmp_path, body, {"MAM_STALL_WATCH": env, "HERDR_SESSION_NAME": "hs"})
|
||||||
|
assert old == new, (old, new)
|
||||||
|
|
||||||
|
|
||||||
|
INT_INPUTS = ["30", "0700", "0900", " 30 ", "0", "-5", "abc", "", "1e3", "9999999999", "000000000030",
|
||||||
|
"999999999", "1000000000", " 30 ", "٣٠", "\t7\n", "9" * 5000]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("locale", ["C", "en_US.UTF-8"])
|
||||||
|
@pytest.mark.parametrize("minimum", [0, 1])
|
||||||
|
def test_c_mam_int_and_knob(tmp_path, locale, minimum):
|
||||||
|
old_fns = subprocess.run(["sed", "-n", "/^mam_int()/,/^}/p;/^mam_int_knob()/,/^}/p", str(OLD)],
|
||||||
|
capture_output=True, text=True, check=True).stdout
|
||||||
|
probe = ('for v in "${VALUES[@]}"; do printf "[%s]" "$(mam_int "$v" "$MIN" || echo invalid)"; '
|
||||||
|
'K="$v" mam_int_knob K 42 "$MIN"; done; echo; mam_int_knob UNSET_KNOB 9 "$MIN"\n')
|
||||||
|
arr = "VALUES=(" + " ".join("$'" + v.encode("unicode_escape").decode().replace("'", "\\'") + "'"
|
||||||
|
for v in INT_INPUTS) + ")\n"
|
||||||
|
outs = []
|
||||||
|
for src in (old_fns, f'source "{LIB_INT}"\n'):
|
||||||
|
res = subprocess.run(["bash", "-c", src + arr + probe], capture_output=True, text=True, timeout=120,
|
||||||
|
env={**os.environ, "MIN": str(minimum), "LC_ALL": locale, "LANG": locale})
|
||||||
|
outs.append((res.returncode, res.stdout, res.stderr))
|
||||||
|
assert outs[0] == outs[1], outs
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# S - static guard
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_s_the_wrapper_carries_no_moved_decision():
|
||||||
|
text = "\n".join(l for l in WRAPPER.read_text().splitlines() if not l.lstrip().startswith("#"))
|
||||||
|
for name in ("_map_sub_rc", "_rc_for", "_disk_status", "_recent_disk_heartbeat", "FINALIZE_CLOCK"):
|
||||||
|
assert name not in text, name
|
||||||
|
for knob in ("MAM_DELEGATE_FAIL_CLOSED", "MAM_STALL_WATCH"):
|
||||||
|
assert "${" + knob not in text, knob
|
||||||
|
# subscriber codes are compared nowhere in bash any more
|
||||||
|
assert not re.search(r"-(eq|ne) +\"?\$SUB_RC_", text)
|
||||||
|
assert not re.search(r"= +\"?\$SUB_RC_", text)
|
||||||
|
for label in ("wall_clock_timeout", "deadline_timeout", "agent_blocked", "session_busy"):
|
||||||
|
assert label not in text, label
|
||||||
|
assert "BASH_REMATCH" not in LIB_INT.read_text()
|
||||||
@@ -83,8 +83,9 @@ INPUTS = ["30", "0700", "0900", " 30 ", "0", "-5", "abc", "", "1e3", "9999999999
|
|||||||
# arithmetic, each with where the value goes. B5 fails on any `${MAM_/SKS_/
|
# arithmetic, each with where the value goes. B5 fails on any `${MAM_/SKS_/
|
||||||
# DELEGATE_JOB_/RECONCILE_…:-<digit>…}` that is neither here nor normalised
|
# DELEGATE_JOB_/RECONCILE_…:-<digit>…}` that is neither here nor normalised
|
||||||
# through INT_KNOBS, so a new knob has to be classified to land.
|
# through INT_KNOBS, so a new knob has to be classified to land.
|
||||||
|
# Issue #17 item 4: MAM_DELEGATE_FAIL_CLOSED and MAM_STALL_WATCH are read by
|
||||||
|
# lib_py/delegation/wrapper.py now, not by shell code.
|
||||||
NOT_ARITHMETIC = {
|
NOT_ARITHMETIC = {
|
||||||
"MAM_DELEGATE_FAIL_CLOSED": "string compare", "MAM_STALL_WATCH": "string compare",
|
|
||||||
"MAM_ALLOW_UNSIGNED_DISPATCH": "string compare", "MAM_DELEGATE_JOB_NO_MAIN": "string compare",
|
"MAM_ALLOW_UNSIGNED_DISPATCH": "string compare", "MAM_DELEGATE_JOB_NO_MAIN": "string compare",
|
||||||
"MAM_NO_AUTOSTART": "string compare", "MAM_SHIM_DEBUG": "string compare",
|
"MAM_NO_AUTOSTART": "string compare", "MAM_SHIM_DEBUG": "string compare",
|
||||||
"MAM_NO_SUBSCRIBER_DAEMON": "string compare", "MAM_DELEGATION_PY": "string compare",
|
"MAM_NO_SUBSCRIBER_DAEMON": "string compare", "MAM_DELEGATION_PY": "string compare",
|
||||||
|
|||||||
@@ -385,15 +385,16 @@ def test_event_to_status_mapping_is_frozen(mam_sandbox):
|
|||||||
def test_wrapper_exit_code_mapping_literals_are_frozen(mam_sandbox):
|
def test_wrapper_exit_code_mapping_literals_are_frozen(mam_sandbox):
|
||||||
wrapper = SKILLS / "multi-agent-mux-delegate-job" / "multi-agent-mux-delegate-job"
|
wrapper = SKILLS / "multi-agent-mux-delegate-job" / "multi-agent-mux-delegate-job"
|
||||||
text = wrapper.read_text(encoding="utf-8")
|
text = wrapper.read_text(encoding="utf-8")
|
||||||
for literal in (
|
# Issue #17 item 4: the mapping moved to lib_py/delegation/wrapper.py; the
|
||||||
'if [[ $sub_rc -eq $SUB_RC_COMPLETED ]]; then',
|
# wrapper reaches it only through `_decide`.
|
||||||
'elif [[ $sub_rc -eq $SUB_RC_ERROR ]]; then',
|
for literal in ('_decide sub-status "$sub_rc"', '_decide exit-code "$job_status"', "--sub-rc \"$sub_rc\""):
|
||||||
'elif [[ $sub_rc -eq $SUB_RC_BROKER_UNAVAILABLE ]]; then',
|
|
||||||
'[ "$sub_rc" = "$SUB_RC_WALL_CLOCK" ] && clock="wall"',
|
|
||||||
'job_status="completed"',
|
|
||||||
'job_status="error"',
|
|
||||||
):
|
|
||||||
assert literal in text, literal
|
assert literal in text, literal
|
||||||
|
decide = _import_delegation(mam_sandbox, "wrapper")
|
||||||
|
assert decide.EXIT_CODES == {"completed": 0, "error": 1, "timeout": 2, "broker_unavailable": 3, "cancelled": 5}
|
||||||
|
assert [decide.sub_status(rc, str(mam_sandbox), "none") for rc in range(6)] == [
|
||||||
|
"completed", "error", "timeout", "broker_unavailable", "timeout", "timeout"]
|
||||||
|
assert (decide.SUB_RC_COMPLETED, decide.SUB_RC_ERROR, decide.SUB_RC_NO_EVENT, decide.SUB_RC_BROKER_UNAVAILABLE,
|
||||||
|
decide.SUB_RC_WALL_CLOCK) == tuple(int(v) for v in re.findall(r"\bSUB_RC_[A-Z_]+=([0-9]+)", text))
|
||||||
# Issue #33: the wrapper's SUB_RC_* mirror job_subscriber's RC_* exactly,
|
# Issue #33: the wrapper's SUB_RC_* mirror job_subscriber's RC_* exactly,
|
||||||
# and the wrapper compares subscriber codes only by those names.
|
# and the wrapper compares subscriber codes only by those names.
|
||||||
job_subscriber = _import_delegation(mam_sandbox, "job_subscriber")
|
job_subscriber = _import_delegation(mam_sandbox, "job_subscriber")
|
||||||
|
|||||||
@@ -841,11 +841,16 @@ def test_g9_subscriber_broker_down_no_disk_terminal_exits_3(mam_sandbox, monkeyp
|
|||||||
|
|
||||||
|
|
||||||
def test_g10_delegate_job_rc3_not_mistaken_for_error(mam_sandbox):
|
def test_g10_delegate_job_rc3_not_mistaken_for_error(mam_sandbox):
|
||||||
"""G-10: multi-agent-mux-delegate-job maps rc=3 to broker_unavailable and checks disk status."""
|
"""G-10: multi-agent-mux-delegate-job maps rc=3 to broker_unavailable and checks disk status.
|
||||||
|
Issue #17 item 4: the mapping is lib_py/delegation/wrapper.py's sub_status."""
|
||||||
delegate_script = mam_sandbox / "skills" / "multi-agent-mux-delegate-job" / "multi-agent-mux-delegate-job"
|
delegate_script = mam_sandbox / "skills" / "multi-agent-mux-delegate-job" / "multi-agent-mux-delegate-job"
|
||||||
content = delegate_script.read_text()
|
assert '_decide sub-status "$sub_rc"' in delegate_script.read_text()
|
||||||
assert "elif [[ $sub_rc -eq $SUB_RC_BROKER_UNAVAILABLE ]]; then" in content
|
from lib_py.delegation import wrapper
|
||||||
assert 'job_status="broker_unavailable"' in content
|
reg = mam_sandbox / "g10-reg"
|
||||||
|
reg.mkdir()
|
||||||
|
assert wrapper.sub_status(3, str(reg), "g10") == "broker_unavailable"
|
||||||
|
(reg / "g10.json").write_text('{"status": "completed"}')
|
||||||
|
assert wrapper.sub_status(3, str(reg), "g10") == "completed"
|
||||||
|
|
||||||
|
|
||||||
def test_claude_adapter_ready_tokens_includes_modern_banners():
|
def test_claude_adapter_ready_tokens_includes_modern_banners():
|
||||||
|
|||||||
Reference in new issue
Block a user