diff --git a/.agents/skills/lib_py/loop/runner.py b/.agents/skills/lib_py/loop/runner.py index f573c02..87d6ced 100644 --- a/.agents/skills/lib_py/loop/runner.py +++ b/.agents/skills/lib_py/loop/runner.py @@ -624,8 +624,22 @@ class LoopRunner: today's 3900 - 3600). The grace covers the wrapper's close/publish tail after the wall clock and the 1 s record poll; it is not proportional to the budget because nothing observed would justify that.""" - grace = os.environ.get("MAM_WAIT_GRACE_S", "") - return self.job_budget + (int(grace) if grace.isdigit() else 300) + grace = self._int_knob("MAM_WAIT_GRACE_S", 0) + return self.job_budget + (300 if grace is None else grace) + + def _int_knob(self, name: str, minimum: int) -> Optional[int]: + """$name by _positive_int's rule (the minimums are in + tests/test_issue26_ints.py RUNNER_INT_KNOBS); None when unset, or when + invalid - then with one warning per knob - so the caller uses its default.""" + raw = os.environ.get(name, "") + n = _positive_int(raw, minimum) + if n is None and raw: + warned = self.__dict__.setdefault("_warned_knobs", set()) + if name not in warned: + warned.add(name) + print(f"WARNING: {name}={raw} ignored (not a decimal integer >= {minimum}); using the default", + file=sys.stderr) + return n def wait_for_job(self, job_id: str, max_wait: Optional[float] = None, check_interval: float = 1.0) -> bool: """Blocking wait for job completion or error with safety timeout.""" @@ -745,11 +759,13 @@ class LoopRunner: A terminal record is not a stopped agent (a deadline close does not interrupt it); that is left to the sub-job table and the stale-review guard, since nothing signals such an agent's end.""" - child_env = os.environ.get("MAM_CHILD_WAIT_S") - max_wait = float(child_env) if child_env else float(self.job_wait_s) - wait_src = "MAM_CHILD_WAIT_S" if child_env else \ + child_wait = self._int_knob("MAM_CHILD_WAIT_S", 0) + max_wait = float(child_wait if child_wait is not None else self.job_wait_s) + wait_src = "MAM_CHILD_WAIT_S" if child_wait is not None else \ f"MAM_CHILD_WAIT_S default: job budget {self.job_budget} + {self.job_wait_s - self.job_budget}" - tree_max = int(os.environ.get("MAM_JOB_TREE_MAX", "50")) + tree_max = self._int_knob("MAM_JOB_TREE_MAX", 1) + if tree_max is None: + tree_max = 50 since = self._created_at(root) start = time.time() last = None diff --git a/.agents/skills/multi-agent-mux-loop/SKILL.md b/.agents/skills/multi-agent-mux-loop/SKILL.md index 30d96db..6e4d597 100644 --- a/.agents/skills/multi-agent-mux-loop/SKILL.md +++ b/.agents/skills/multi-agent-mux-loop/SKILL.md @@ -206,7 +206,7 @@ sequenceDiagram | **Phase 3: Consensus** | `--all-reviewer` | 레지스트리에 등록된 모든 active 리뷰어 세션을 자동으로 수집하여 리뷰를 돌립니다. (`--reviewer` 옵션과는 상호 배타적이며, 지정/수집된 모든 리뷰어의 PASS 만장일치가 항상 필요합니다.) | | **Iterative Loop** | `--max-loop M` | NOT PASS 판정 시 최대 `M`회까지 Creator가 자체 수정합니다. `--plan` 모드에서 리뷰어가 리포트에 `[ESCALATE: PLANNER]` 태그를 남기면 설계 변경 수준으로 판단하여 Planner에게 계획 갱신을 위임합니다 (린트는 리뷰어가 검토 관점 중 하나로 확인할 뿐, 별도의 자동 게이트는 아닙니다). | | **Rebuttal** | `--max-rebut N` | 이터레이션당 Creator 반론 횟수 (기본 1, `0`이면 끔). | -| **Job budget** | `--job-timeout ` | 위임 잡마다의 예산 (기본 `MAM_JOB_TIMEOUT_S` 또는 3600). 잡 대기 상한은 예산 + 300 s. | +| **Job budget** | `--job-timeout ` | 위임 잡마다의 예산 (기본 `MAM_JOB_TIMEOUT_S` 또는 3600). 잡 대기 상한은 예산 + `MAM_WAIT_GRACE_S`(기본 300 s, 0 이상의 10진 정수; 잘못된 값은 경고 후 300) — 벽시계 뒤 래퍼의 종결·발행 꼬리와 1 s 레코드 폴링을 덮는 여유입니다. | | **Control** | `--verbose` / `--cleanup` | 상세 로그 / 성공 시 임시 job 디렉터리 삭제. | | **Source root** | `--worktree ` (env `MAM_LOOP_WORKTREE`, CLI 우선) | diff 수집·base·head·빈 변경분 판정만 이 git 워크트리 기준으로 합니다. wrapper·레지스트리·체크포인트·loop guard·리포트 승격·cleanup 은 루프를 띄운 본 체크아웃(제어 루트)에 남습니다. 모든 위임 프롬프트 앞에 `[워크트리 컨텍스트]` 블록(경로·브랜치·base·head·커밋 수·미커밋 변경)이 붙습니다. Python 루프 전용(Issue #21). | | **Diff base** | `--base ` (CLI 전용, env 없음) | 리뷰 diff 의 base 를 고정합니다. 소스 HEAD 의 조상이어야 하며 아니면 exit 1. 생략 시 Phase 2 시작 시점의 소스 HEAD 입니다. | @@ -219,7 +219,7 @@ sequenceDiagram **리뷰 identity (Issue #21 항목 5).** `--worktree`/`--base` 가 있으면 매 리뷰 라운드를 `(loop_id, base, head, tree)` 에 묶습니다. 소스의 미커밋·untracked 상태까지 담은 스냅샷(ref 없는 커밋)을 만들고, 리뷰어가 받는 diff 도 그 스냅샷에서 만듭니다(`base..snapshot`). 라운드가 끝난 뒤 소스가 바뀌었으면 그 라운드의 PASS 는 버리고 수정 잡 없이 다시 리뷰합니다(마지막 반복이면 exit 1). 승격 직전에도 한 번 더 확인하며, 승격된 리포트 옆에 `report-.identity.json`(loop_id·base·head·tree·스냅샷 커밋·리뷰어·`transport`·`evidence`: `review-tree`/`source-tree`/`diff-only`)을 둡니다. 추적되지 않는 중첩 git 저장소는 스냅샷에 담을 수 없어 거부합니다. 서브모듈 안의 미커밋·untracked 내용도 스냅샷에는 gitlink 커밋만 남으므로 거부합니다(라운드 시작 시 exit 1, 리뷰 중에 생기면 그 라운드는 stale). `submodule.*.ignore` 설정으로 가려지지 않습니다. 리뷰어가 없으면 creator 셀프 리뷰가 같은 리뷰어 경로(스냅샷 diff·증거 검사·승격 직전 재확인·사이드카)를 거치고, NOT PASS 면 수정 잡 없이 다시 리뷰합니다. 리뷰 잡의 `[워크트리 컨텍스트]` 는 검증 대상을 `[리뷰 identity]` 의 검증 경로 하나로만 지시합니다. -**하위 위임 잡 (Issue #34).** creator 가 작업 일부를 `delegate-job submit --parent-job <자기 job_id>` 로 맡기면, 루프는 creator 잡(실행·수정·순응)이 끝난 뒤 그 잡의 자손(자식·손자…, 폴링마다 다시 읽음)의 레코드가 모두 종결될 때까지 기다린 다음 리뷰로 넘어갑니다. 깊이 제한은 없고 상한은 둘입니다: `MAM_CHILD_WAIT_S`(기본 잡 예산 + 300 s, creator 잡 종결부터; 각 하위 잡은 자기 등록 + `timeout_sec`(#33, 기본 `MAM_JOB_TIMEOUT_S`/3600 s)에 닫히므로 보통 그 전에 끝납니다)와 `MAM_JOB_TREE_MAX`(기본 50 개, 넘으면 기다리지 않음). 그만큼 루프 벽시계가 늘어납니다. 루프를 잡으로 감싸 돌리면 바깥 잡의 3600 s 가 먼저 끝날 수 있습니다. 자식의 결과는 루프를 멈추지 않고 리뷰 근거가 됩니다: 하위 잡이나 귀속되지 않은 열린 잡이 있으면 모든 리뷰 프롬프트(셀프 리뷰 포함) 앞에 `[하위 위임 잡]` 표(상태·사유·깊이)와 `[귀속되지 않은 열린 잡]`(부모 미표기로 이 작업 중 등록된 열린 잡) 줄이 붙고(둘 다 없으면 프롬프트는 전과 같습니다), 리뷰어는 `OPEN`·`CLOSED-UNCONFIRMED`·실패(`error`/`cancelled`/`NOT-DISPATCHED`) 하위 잡이 있으면 creator 리포트가 대체 완료를 입증하지 않는 한 NOT PASS 를 냅니다. 표는 이 실행의 모든 creator 잡(앞 회차의 실행·수정·순응 잡 포함)의 자손을 매 회차 다시 읽어 만듭니다 — 수정 잡이 새로 생겼다고 앞 잡의 열린·실패 하위 잡이 사라지지 않습니다(대기는 새 creator 잡의 자손만 하므로 대기 상한이 회차마다 다시 쌓이지 않습니다). `CLOSED-UNCONFIRMED` 는 오케스트레이터가 마감(`deadline_timeout`/`wall_clock_timeout`/`agent_blocked`)으로 닫았거나 사유가 없는 `error` 입니다 — 레코드만 닫혔고 에이전트는 인터럽트되지 않으므로 계속 쓰고 있을 수 있습니다. creator 잡 자체의 실패는 전처럼 exit 1 입니다. +**하위 위임 잡 (Issue #34).** creator 가 작업 일부를 `delegate-job submit --parent-job <자기 job_id>` 로 맡기면, 루프는 creator 잡(실행·수정·순응)이 끝난 뒤 그 잡의 자손(자식·손자…, 폴링마다 다시 읽음)의 레코드가 모두 종결될 때까지 기다린 다음 리뷰로 넘어갑니다. 깊이 제한은 없고 상한은 둘입니다: `MAM_CHILD_WAIT_S`(기본 잡 예산 + `MAM_WAIT_GRACE_S`, creator 잡 종결부터; 0 이면 한 번만 확인; 각 하위 잡은 자기 등록 + `timeout_sec`(#33, 기본 `MAM_JOB_TIMEOUT_S`/3600 s)에 닫히므로 보통 그 전에 끝납니다)와 `MAM_JOB_TREE_MAX`(기본 50 개, 1 이상, 넘으면 기다리지 않음). 둘 다 10진 정수이고 잘못된 값은 경고 후 기본값을 씁니다. 그만큼 루프 벽시계가 늘어납니다. 루프를 잡으로 감싸 돌리면 바깥 잡의 3600 s 가 먼저 끝날 수 있습니다. 자식의 결과는 루프를 멈추지 않고 리뷰 근거가 됩니다: 하위 잡이나 귀속되지 않은 열린 잡이 있으면 모든 리뷰 프롬프트(셀프 리뷰 포함) 앞에 `[하위 위임 잡]` 표(상태·사유·깊이)와 `[귀속되지 않은 열린 잡]`(부모 미표기로 이 작업 중 등록된 열린 잡) 줄이 붙고(둘 다 없으면 프롬프트는 전과 같습니다), 리뷰어는 `OPEN`·`CLOSED-UNCONFIRMED`·실패(`error`/`cancelled`/`NOT-DISPATCHED`) 하위 잡이 있으면 creator 리포트가 대체 완료를 입증하지 않는 한 NOT PASS 를 냅니다. 표는 이 실행의 모든 creator 잡(앞 회차의 실행·수정·순응 잡 포함)의 자손을 매 회차 다시 읽어 만듭니다 — 수정 잡이 새로 생겼다고 앞 잡의 열린·실패 하위 잡이 사라지지 않습니다(대기는 새 creator 잡의 자손만 하므로 대기 상한이 회차마다 다시 쌓이지 않습니다). `CLOSED-UNCONFIRMED` 는 오케스트레이터가 마감(`deadline_timeout`/`wall_clock_timeout`/`agent_blocked`)으로 닫았거나 사유가 없는 `error` 입니다 — 레코드만 닫혔고 에이전트는 인터럽트되지 않으므로 계속 쓰고 있을 수 있습니다. creator 잡 자체의 실패는 전처럼 exit 1 입니다. **리뷰 중 소스 변경 = 승인 무효 (하위 잡이 있을 때, Issue #34).** `--worktree`/`--base` 가 없어도 이 실행에 하위 잡(또는 귀속되지 않은 열린 잡)이 있는 라운드마다 리뷰 요청 직전 소스(= 제어 루트)의 head 와 미커밋·untracked 를 포함한 tree 를 기록하고, 판정 수집 뒤와 승격 직전에 다시 계산합니다. 바뀌었으면 위 identity 모드와 같이 그 라운드의 PASS 를 버리고 수정 잡 없이 다시 리뷰합니다(마지막 반복이면 exit 1). 제어 평면(`.mam`, 잡·감사 로그 디렉터리)은 비교에서 뺍니다. 리뷰어가 ignore 되지 않는 산출물을 소스에 남기면 라운드가 stale 이 되니 산출물은 소스 밖에 둡니다. 하위 잡이 없는 루프는 이 검사를 하지 않습니다(전과 같음) — 소스에 잡마다 바뀌는 untracked 파일이 있을 수 있기 때문입니다. git 저장소가 아니거나 서브모듈이 dirty 해 지문을 만들 수 없으면 경고(`stale-review guard is off`) 후 기존 동작입니다. 루프가 끝난 **뒤**의 늦은 쓰기는 막을 수 없으며 다음 루프의 diff 에 미커밋 변경으로 드러납니다. diff --git a/tests/test_issue26_ints.py b/tests/test_issue26_ints.py index 1e05ab7..76766ca 100644 --- a/tests/test_issue26_ints.py +++ b/tests/test_issue26_ints.py @@ -53,6 +53,15 @@ INT_KNOBS = { "--ack-timeout": (1, None), } +# The loop runner's own integer knobs (follow-up F4a): read by runner._int_knob +# (_positive_int's rule) with these minimums; an invalid value warns once and +# the default is used - never an exception. knob -> (minimum, default) +RUNNER_INT_KNOBS = { + "MAM_WAIT_GRACE_S": (0, 300), # 0: wait exactly the job budget + "MAM_CHILD_WAIT_S": (0, None), # 0: look at the sub-jobs once; default: budget + grace + "MAM_JOB_TREE_MAX": (1, 50), # 0 would refuse every tree as "over"; not waiting is MAM_CHILD_WAIT_S=0 +} + # The last two are longer than Python's int() digit limit (4300): the rule must # bound the significant digits before converting (review R3). INPUTS = ["30", "0700", "0900", " 30 ", "0", "-5", "abc", "", "1e3", "9999999999", "000000000030", @@ -132,6 +141,21 @@ def _runner(home: Path, extra_env=None, *argv) -> subprocess.CompletedProcess: env=_base_env(home, extra_env), cwd=str(home), timeout=60) +def _runner_wait(home: Path, extra_env=None, kids: int = 0) -> tuple: + """The real runner waiting on a sub-job tree of `kids` open jobs (budget 1 s): + ({grace, ok}, everything it printed).""" + code = ("import json, sys; from lib_py.loop import runner as lr; " + "r = lr.LoopRunner(['--creator', 'c', '--task', 't', '--job-timeout', '1']); r.parse_args(); " + "lr.job_registry.descendants_of = lambda root, d: [{'job_id': 'k%d' % i, 'status': 'running'} " + "for i in range(int(sys.argv[1]))]; " + "ok = r.wait_for_descendants('root', interval=0.1); " + "print(json.dumps({'grace': r.job_wait_s - r.job_budget, 'ok': ok}))") + res = subprocess.run([sys.executable, "-c", code, str(kids)], capture_output=True, text=True, + env=_base_env(home, {k: v for k, v in (extra_env or {}).items()}), cwd=str(home), timeout=60) + assert res.returncode == 0 and "Traceback" not in res.stderr, res.stderr[-800:] + return json.loads(res.stdout.strip().splitlines()[-1]), res.stdout + res.stderr + + def _bash(snippet: str, home: Path, extra_env=None) -> subprocess.CompletedProcess: return subprocess.run(["bash", "-c", f'source "{LIB_SH}" >/dev/null 2>&1; {snippet}'], capture_output=True, text=True, env=_base_env(home, extra_env), cwd=str(home), timeout=60) @@ -214,6 +238,50 @@ def test_b1_sks_knobs_keep_zero_and_read_decimal(tmp_path, knob): assert ("ignored" in res.stderr) is warns, (value, res.stderr) +@pytest.mark.parametrize("value", INPUTS, ids=_id) +def test_b1_runner_wait_grace_is_decimal_or_the_default(tmp_path, value): + minimum, default = RUNNER_INT_KNOBS["MAM_WAIT_GRACE_S"] + want = _expected(value, minimum) + got, out = _runner_wait(tmp_path, {"MAM_WAIT_GRACE_S": value}) + assert got["grace"] == (default if want is None else want), out[-500:] + assert ("WARNING: MAM_WAIT_GRACE_S=" in out and "ignored" in out) is (want is None and value != ""), out[-500:] + + +@pytest.mark.parametrize("value,waited,warns", [("0", "0s (MAM_CHILD_WAIT_S)", False), + (" 0 ", "0s (MAM_CHILD_WAIT_S)", False), + ("01", "1s (MAM_CHILD_WAIT_S)", False), + ("abc", "1s (MAM_CHILD_WAIT_S default", True), + ("-5", "1s (MAM_CHILD_WAIT_S default", True), + ("0.5", "1s (MAM_CHILD_WAIT_S default", True), + ("9999999999", "1s (MAM_CHILD_WAIT_S default", True), + ("", "1s (MAM_CHILD_WAIT_S default", False)]) +def test_b1_runner_child_wait_is_decimal_or_the_default(tmp_path, value, waited, warns): + """MAM_CHILD_WAIT_S=abc raised ValueError; the default is budget + grace (1 + 0 here).""" + got, out = _runner_wait(tmp_path, {"MAM_CHILD_WAIT_S": value, "MAM_WAIT_GRACE_S": "0"}, kids=1) + assert got["ok"] is False and f"ended after {waited}" in out, out[-500:] + assert ("WARNING: MAM_CHILD_WAIT_S=" in out and "ignored" in out) is warns, out[-500:] + + +@pytest.mark.parametrize("value,over,warns", [("2", True, False), ("03", False, False), (" 2 ", True, False), + ("0", False, True), ("abc", False, True), ("-1", False, True), + ("", False, False)]) +def test_b1_runner_job_tree_max_is_decimal_or_the_default(tmp_path, value, over, warns): + """Three open sub-jobs against the cap; an invalid cap is 50 with a warning.""" + got, out = _runner_wait(tmp_path, {"MAM_JOB_TREE_MAX": value, "MAM_CHILD_WAIT_S": "0"}, kids=3) + assert got["ok"] is False + assert ("over MAM_JOB_TREE_MAX=" in out) is over, out[-500:] + assert ("ended after 0s" in out) is (not over), out[-500:] + assert ("WARNING: MAM_JOB_TREE_MAX=" in out and "ignored" in out) is warns, out[-500:] + + +def test_b5_runner_knobs_use_the_table_minimum(): + src = (SKILLS / "lib_py" / "loop" / "runner.py").read_text() + calls = dict(re.findall(r'_int_knob\("([A-Z_]+)", (\d+)\)', src)) + assert {k: int(v) for k, v in calls.items()} == {k: m for k, (m, _) in RUNNER_INT_KNOBS.items()} + for name in RUNNER_INT_KNOBS: + assert f'os.environ.get("{name}"' not in src, f"{name} is read raw" + + # --------------------------------------------------------------------------- # B2 - flags # ---------------------------------------------------------------------------