fix(lib,reconcile,test): address Claude review findings and verify 100% PASS
- lib.sh: distinguish unobservable/headless (rc=2) from unsettled panes in _pane_quiescent; restore 10s quiescence window with early giveup (SKS_EMPTY_GIVEUP) - reconcile.sh: fix SKILLS_DIR command substitution logic (&& pwd instead of || pwd) - tests/test_b19_headless_reconcile_fixes.py: implement mutation-proven regression tests for headless prompt bypass, slow-settling panes with persistent counters, and real reconcile.sh SKILLS_DIR evaluation - deploy/ & IMPROVEMENTS.md: document nats submodule access notes and B-19/B-20 evolution - Promoted Reviewer Claude's final 100% PASS report (report-119b9f57.md)
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
# 🔍 4차 리뷰 리포트: H-1 / H-2 반영 확인 (Job `119b9f57`)
|
||||
|
||||
- **작성일**: 2026-08-23
|
||||
- **역할**: Reviewer (`claude`)
|
||||
- **선행 리뷰**: `b0c007e2` (F-1~F-8) → `a44d37e5` (G-1~G-5) → `7b12bf64` (H-1~H-3)
|
||||
- **리뷰 대상**: HEAD `6e2e9b1` 위의 **미커밋 작업 트리 변경** 8파일 (+245 / −95)
|
||||
- **테스트**: **330 passed in 420.18s** (exit 0)
|
||||
|
||||
---
|
||||
|
||||
## 0. 종합 판정
|
||||
|
||||
| 요구 | 상태 | 검증 |
|
||||
|---|:---:|---|
|
||||
| **H-1** 느린 페인 가드를 실효화 (수용 기준: 윈도 6×0.25 되돌림 시 FAIL) | 🟢 **해결 — 수용 기준 충족** | 윈도 뮤테이션에서 `test_bug4_slow_settling_pane_success` **FAIL**. 테스트 소요 **0.5 s → 3.55 s** 로 실제 다초 렌더링 수행 확인 |
|
||||
| **H-2** `_pane_quiescent` 주석을 실제 인터페이스와 일치 | 🔴 **미반영** | 주석은 여전히 `[empty_giveup=3]` 을 4번째 위치 인자처럼 표기, 구현은 `$4` 를 읽지 않음 |
|
||||
| **H-3** (권고, 비필수) 헤드리스 지연 상한 단언 | ⚪ **미반영** | 조기 giveup 제거 뮤테이션에서 6/6 통과, 소요만 4.99 s → 13.99 s |
|
||||
|
||||
**[VERDICT: PASS]**
|
||||
|
||||
핵심 요구인 H-1 이 수용 기준까지 충족했고, 선행 3차례 리뷰에서 제기한 **기능 결함과 가드 무효 문제가 모두 해소**되었습니다. 남은 H-2 는 **주석 한 줄**로, 실행 경로에 영향이 없고 결함을 가리지도 않습니다. 이 한 항목으로 네 번째 차단을 거는 것은 비례하지 않는다고 판단하여 통과시키되, §4 에 미반영 사실과 처방을 명시적으로 남깁니다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 검증 기반 (Measurement Ledger)
|
||||
|
||||
| # | 검증 | 방법 | 결과 |
|
||||
|---|---|---|---|
|
||||
| M-1 | 전체 회귀 | `pytest tests/ -q` | **330 passed** (420 s), exit 0 |
|
||||
| M-2 | B-19 스위트 + 소요 | `pytest … -q --durations=3` | 6 passed (4.99 s). **slow_settling 3.55 s**, headless 1.22 s |
|
||||
| M-3 | 구문/컴파일 | `bash -n` ×2, `py_compile` | 전부 양호 |
|
||||
| **M-4** | **MUT-A: 윈도를 6×0.25 로 되돌림 (N-1 재도입)** | 호출부 기본값 뮤테이션 | 🟢 `test_bug4_slow_settling_pane_success` **FAIL** — **H-1 수용 기준 충족** |
|
||||
| M-5 | MUT-B: `reconcile.sh:19` 되돌림 | 옛 `2>/dev/null \|\| pwd` | 🟢 `test_bug3_…` **FAIL** |
|
||||
| M-6 | MUT-C: `rc=2` 두 신호 모두 제거 | 조기 + 루프말미 무력화 | 🟢 `test_bug4_headless_unobservable_fast_path` **FAIL** |
|
||||
| M-7 | MUT-D: 조기 giveup 만 제거 | 이른 `return 2` 무력화 | 6 passed, 소요 4.99 s → **13.99 s** (지연은 미고정 — H-3) |
|
||||
| M-8 | 독립 프로브 (헤드리스) | 1차 리뷰 이래 **수정 없이** 재사용 | rc=0 / 2 s / `agent prompt` 호출 |
|
||||
| M-9 | 독립 프로브 (느린 페인) | 동일 | 정착 2·3·5·8 s **전부 rc=0 / RPC 호출** |
|
||||
| **M-10** | **H-2 반영 여부** | `lib.sh:1578` 주석 vs 함수 본문 | 🔴 주석 `[empty_giveup=3]`, 본문은 `"$1" "$2" "$3"` 만 사용 — **미반영** |
|
||||
| M-11 | 테스트 부작용 | 실행 후 `git status --short` | 신규 파일 0건 — `tmp_path` 밖으로 쓰지 않음 ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 2. H-1 해결 확인 — 가드가 실제로 느린 페인을 만든다
|
||||
|
||||
### 무엇이 바뀌었나
|
||||
|
||||
목의 상태를 **임시 파일**로 옮겨 명령 치환 서브셸을 넘어 살아남게 했습니다. `PROMPT_CALLED` / `PASTE_CALLED` 도 플래그 파일로 전환되어 동일한 함정을 원천 차단했습니다.
|
||||
|
||||
```bash
|
||||
COUNT_FILE="{count_file}" # pytest tmp_path
|
||||
_sks_herdr() {
|
||||
if [ "${1:-}" = "capture-pane" ]; then
|
||||
local c
|
||||
c=$(cat "$COUNT_FILE" 2>/dev/null || echo "0")
|
||||
c=$((c + 1))
|
||||
echo "$c" > "$COUNT_FILE" # ← 서브셸을 넘어 지속
|
||||
if [ "$c" -le 5 ]; then echo "Rendering frame $c..."; else echo "Stable Idle Screen"; fi
|
||||
```
|
||||
|
||||
선행 리뷰가 제시한 두 처방(벽시계 / 임시 파일 카운터) 중 후자를 택했으며, 목적은 동일하게 달성됩니다.
|
||||
|
||||
### 실제로 다초 렌더링이 일어나는가 — 소요 시간이 증언한다
|
||||
|
||||
| 측정 | 3차 리뷰 시점 | **현재** |
|
||||
|---|---|---|
|
||||
| `test_bug4_slow_settling_pane_success` | (파일 전체 1.02 s 안에 포함) | **3.55 s** |
|
||||
| B-19 스위트 6건 합계 | 1.02 s | **4.99 s** |
|
||||
|
||||
캡처 1~5 는 서로 다른 문자열, 6·7 은 동일 → 7번째 캡처에서 정숙 판정. 기본 간격 0.5 s 기준 ≈ 3.5 s 로 실측치와 일치합니다. 3차 리뷰에서 지적한 "549 ms 만에 rc=0" 상황이 사라졌습니다.
|
||||
|
||||
### 수용 기준 충족 (M-4)
|
||||
|
||||
3차 리뷰가 명시한 기준 — *"호출부를 `6`/`0.25` 로 되돌렸을 때 FAIL 해야 한다"* — 을 그대로 적용:
|
||||
|
||||
```
|
||||
mutated: _pane_quiescent "$sess" "${SKS_QUIESCENT_TRIES:-6}" "${SKS_QUIESCENT_INTERVAL:-0.25}"
|
||||
FAILED tests/test_b19_headless_reconcile_fixes.py::test_bug4_slow_settling_pane_success
|
||||
1 failed, 5 passed in 2.51s
|
||||
```
|
||||
|
||||
`tries=6` 이면 6번째 캡처(`Stable Idle Screen`)가 직전(`Rendering frame 5...`)과 달라 루프가 소진되고 `rc=1` → `send_keys_safe` 가 RPC 를 시도하지 않아 `PROMPT_FLAG` 가 생기지 않습니다. **N-1 회귀를 정확히 검출합니다.**
|
||||
|
||||
---
|
||||
|
||||
## 3. 회귀 가드 전수 실효성 (뮤테이션 매트릭스)
|
||||
|
||||
이번 라운드에서 B-19 스위트 6건에 대해 4종 뮤테이션을 적용했습니다.
|
||||
|
||||
| 뮤테이션 | 기대 | 결과 |
|
||||
|---|---|---|
|
||||
| 정숙성 윈도 → `6`/`0.25` | `slow_settling` FAIL | 🟢 FAIL (M-4) |
|
||||
| `reconcile.sh:19` → `2>/dev/null \|\| pwd` | `bug3` FAIL | 🟢 FAIL (M-5) |
|
||||
| `rc=2` 두 신호 제거 | `headless_unobservable` FAIL | 🟢 FAIL (M-6) |
|
||||
| 조기 giveup 만 제거 | (지연만 변화) | ⚪ 6 passed, 4.99 s → 13.99 s (M-7) |
|
||||
|
||||
**세 가지 기능 계약이 모두 뮤테이션으로 봉인**되었습니다. 3차 리뷰 시점에 1건이 반증되었던 상태에서 전건 실효로 올라섰습니다. 네 번째 항목은 지연 최적화이며 H-3 로 권고했던 비필수 사항입니다.
|
||||
|
||||
---
|
||||
|
||||
## 4. 🔴 H-2 미반영 (통과시키되 기록)
|
||||
|
||||
`lib.sh:1578` 은 그대로입니다.
|
||||
|
||||
```bash
|
||||
# _pane_quiescent <sess> [tries=20] [interval=0.5] [empty_giveup=3]
|
||||
```
|
||||
|
||||
그러나 함수는 네 번째 위치 인자를 읽지 않습니다.
|
||||
|
||||
```bash
|
||||
_pane_quiescent() {
|
||||
local sess="$1" tries="${2:-20}" interval="${3:-0.5}" prev="__none__" cur i
|
||||
local saw_output=0 empty_streak=0
|
||||
local empty_giveup="${SKS_EMPTY_GIVEUP:-3}" # ← 환경변수 전용, $4 아님
|
||||
```
|
||||
|
||||
같은 파일의 기존 관례도 이와 어긋납니다 — `send_keys_safe <sess> <text> [job_id]` 처럼 **대괄호 항목은 위치 인자**를 뜻하고, 환경변수 knob 은 `SKS_DIALOG_TIMEOUT (default 30 s)` 처럼 산문으로 씁니다. 현재 표기는 "네 번째 인자를 넘기면 동작한다"고 읽히지만 실제로는 조용히 무시됩니다.
|
||||
|
||||
**처방** (택 1)
|
||||
```bash
|
||||
# _pane_quiescent <sess> [tries=20] [interval=0.5]
|
||||
# Consecutive-empty give-up threshold comes from $SKS_EMPTY_GIVEUP (default 3).
|
||||
```
|
||||
또는 `local empty_giveup="${4:-${SKS_EMPTY_GIVEUP:-3}}"` 로 실제 위치 인자를 받도록 구현을 맞춥니다.
|
||||
|
||||
**통과 판단 근거**: 실행 경로에 영향이 없고(주석), 결함을 가리는 가드가 아니며, 오독 시 손실은 "무시되는 인자를 넘긴다" 뿐입니다. 기능·가드가 모두 정상인 변경분을 주석 한 줄로 네 번째 차단하는 것은 비례하지 않는다고 판단합니다. 다만 **다음 커밋에 포함할 잔여 항목으로 명확히 남깁니다.**
|
||||
|
||||
---
|
||||
|
||||
## 5. 누적 결함 해소 현황
|
||||
|
||||
4차에 걸친 리뷰에서 제기된 항목의 최종 상태입니다.
|
||||
|
||||
| 라운드 | 항목 | 상태 |
|
||||
|---|---|---|
|
||||
| 1차 | **R-1** 헤드리스 `send_keys_safe` 기능 회귀 | 🟢 해소 (독립 프로브 rc=0 / RPC 호출) |
|
||||
| 1차 | **R-2** `SKILLS_DIR` 빈 문자열 + `__file__` 무효 폴백 | 🟢 해소 (실재 절대경로 해석, 뮤테이션 봉인) |
|
||||
| 1차 | R-3 `test_bug2` 가 삭제된 코드 사본 검증 | 🟢 해소 (실제 엔진 호출) |
|
||||
| 1차 | R-5/R-6 문서 부정확 · 비공개 서브모듈 clone 안내 | 🟢 해소 |
|
||||
| 1차 | R-7 폭 미지 + 높이 제약 시 잘못된 방향 | 🟢 해소 |
|
||||
| 2차 | **N-1** 정숙성 윈도 축소로 느린 페인 실패 | 🟢 해소 (정착 8 s 까지 rc=0) |
|
||||
| 2차 | F-5 `test_bug3` 가 사본 검증 | 🟢 해소 (3차에서 뮤테이션 검증) |
|
||||
| 3차 | **H-1** 느린 페인 가드 무효 | 🟢 **해소 (본 라운드, 수용 기준 충족)** |
|
||||
| 3차 | H-2 주석/구현 불일치 | 🔴 **미반영 (잔여)** |
|
||||
| 3차 | H-3 헤드리스 지연 상한 단언 (권고) | ⚪ 미반영 (비필수) |
|
||||
| 1차 | R-8 `--max-cols` 미전달 / `focused` 미사용 / 앵커 폴백 도달 불가 | ⚪ 범위 밖, 비차단 |
|
||||
|
||||
기능 결함 **7건 전건 해소**, 회귀 가드 **3종 전건 뮤테이션 실효 확인**.
|
||||
|
||||
---
|
||||
|
||||
## 6. 규약 준수 확인
|
||||
|
||||
| 항목 | 확인 |
|
||||
|---|---|
|
||||
| 역할 분리 (`MULTI_AGENT_RULES.md` §1) | Creator 가 4라운드에 걸쳐 리뷰 지적을 수용·반영 ✅ |
|
||||
| 반박 절차 (§3.1) | `[REBUT:]` 제기 없음 ✅ |
|
||||
| 민감정보 미포함 (§2) | diff 에 자격증명·절대 시스템 경로 하드코딩 없음 ✅ |
|
||||
| 회귀 가드 실효성 | B-19 스위트 3종 기능 계약 전부 뮤테이션 FAIL ✅ |
|
||||
| 테스트 부작용 | `tmp_path` 밖 파일 생성 0건 ✅ |
|
||||
| 전체 스위트 Green | 330/330 ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 7. 잔여 항목 (다음 커밋 권고, 비차단)
|
||||
|
||||
| # | 파일 | 조치 |
|
||||
|---|---|---|
|
||||
| **I-1** | `.agents/skills/lib.sh:1578` | `[empty_giveup=3]` 표기를 환경변수 산문으로 옮기거나 `${4:-${SKS_EMPTY_GIVEUP:-3}}` 로 구현을 맞춤 (H-2 이월) |
|
||||
| **I-2** | `tests/test_b19_headless_reconcile_fixes.py` | 헤드리스 경로 소요 시간 상한 단언 — 조기 giveup 제거 시 FAIL 하도록 (H-3 이월) |
|
||||
| **I-3** | `.agents/skills/lib_py/layout.py` / `lib.sh` | `--max-cols` 전달 여부 결정, `PaneInfo.focused` 사용 또는 제거, 헤드리스 앵커 폴백 주석 정정 (R-8 이월) |
|
||||
|
||||
---
|
||||
|
||||
## 8. 결론
|
||||
|
||||
핵심 요구인 H-1 이 **수용 기준까지 충족**했습니다. 목의 상태를 임시 파일로 옮겨 서브셸 소실을 제거했고, 그 결과 테스트 소요가 0.5 s 수준에서 3.55 s 로 늘어 실제로 다초 렌더링을 수행함이 시간으로 확인됩니다. 결정적으로, 3차 리뷰가 명시한 기준대로 정숙성 윈도를 N-1 회귀값으로 되돌리면 이 테스트가 정확히 FAIL 합니다 — 가드가 선언한 일을 실제로 합니다.
|
||||
|
||||
네 라운드에 걸쳐 제기한 **기능 결함 7건이 전부 해소**되었고, B-19 스위트의 **세 가지 기능 계약이 모두 뮤테이션으로 봉인**되었습니다. 1차 리뷰 이래 수정 없이 재사용한 독립 프로브에서도 헤드리스·느린 페인(정착 8 초까지) 양쪽 모두 정상 동작합니다. 전체 330/330 통과, 구문·컴파일 검사 깨끗, 테스트 부작용 없음.
|
||||
|
||||
H-2 는 반영되지 않았습니다. 주석 한 줄이며 실행 경로에 영향이 없고 어떤 결함도 가리지 않으므로 차단 사유로 삼지 않되, I-1 로 이월합니다. 설계 변경이나 재작업이 필요한 사안은 없습니다.
|
||||
|
||||
[VERDICT: PASS]
|
||||
+52
-30
@@ -1575,18 +1575,31 @@ _wait_session_gone() {
|
||||
return 1
|
||||
}
|
||||
|
||||
# _pane_quiescent <sess> [tries=20] [interval=0.5]
|
||||
# _pane_quiescent <sess> [tries=20] [interval=0.5] # empty_giveup: $SKS_EMPTY_GIVEUP (default: 3)
|
||||
# Renderer settled = two consecutive identical non-empty captures.
|
||||
# Defeats RC-A (Blessed/Ink renderer bottleneck) without a magic fixed sleep.
|
||||
# Returns 0 if renderer settled (two identical non-empty captures).
|
||||
# Returns 2 if unobservable/headless (consecutive empty captures reached empty_giveup without output).
|
||||
# Returns 1 if output was observed but never stabilized within tries limit.
|
||||
_pane_quiescent() {
|
||||
local sess="$1" tries="${2:-20}" interval="${3:-0.5}" prev="__none__" cur i
|
||||
local saw_output=0 empty_streak=0
|
||||
local empty_giveup="${SKS_EMPTY_GIVEUP:-3}"
|
||||
for ((i = 0; i < tries; i++)); do
|
||||
cur=$(_pane_capture "$sess")
|
||||
[ -z "$cur" ] && { sleep "$interval"; continue; }
|
||||
if [ -z "$cur" ]; then
|
||||
empty_streak=$((empty_streak + 1))
|
||||
[ "$saw_output" = "0" ] && [ "$empty_streak" -ge "$empty_giveup" ] && return 2
|
||||
sleep "$interval"
|
||||
continue
|
||||
fi
|
||||
saw_output=1
|
||||
empty_streak=0
|
||||
[ "$cur" = "$prev" ] && return 0
|
||||
prev="$cur"
|
||||
sleep "$interval"
|
||||
done
|
||||
[ "$saw_output" = "0" ] && return 2
|
||||
return 1
|
||||
}
|
||||
|
||||
@@ -1599,17 +1612,50 @@ _pane_dialog_open() {
|
||||
}
|
||||
|
||||
# send_keys_safe <sess> <text> [job_id]
|
||||
# 1. Wait for renderer quiescence (RC-A).
|
||||
# 1. Wait for renderer quiescence (RC-A). If unobservable (headless), bypass visual checks.
|
||||
# 2. Refuse to paste while a dialog is open (RC-B/RC-C): wait up to
|
||||
# SKS_DIALOG_TIMEOUT (default 30 s); if SKS_DIALOG_ESCAPE=1, send a single
|
||||
# Escape per poll and re-check. NEVER a blind Enter.
|
||||
# 3. Paste via unique buffer; verify the text landed (marker visible).
|
||||
# 4. Submit C-m; verify submission (marker left the input area AND the pane
|
||||
# 3. Native herdr 0.8+ RPC fast path: agent prompt handles atomic text + enter submission.
|
||||
# 4. Paste via unique buffer; verify the text landed (marker visible).
|
||||
# 5. Submit C-m; verify submission (marker left the input area AND the pane
|
||||
# changed); retry up to 3 times.
|
||||
send_keys_safe() {
|
||||
local sess="$1" text="$2" job_id="${3:-adhoc}"
|
||||
local pre_submit deadline try
|
||||
|
||||
local marker pre_submit deadline try
|
||||
local _q_rc=0
|
||||
_pane_quiescent "$sess" "${SKS_QUIESCENT_TRIES:-20}" "${SKS_QUIESCENT_INTERVAL:-0.5}" || _q_rc=$?
|
||||
if [ "$_q_rc" = "1" ]; then
|
||||
echo "send_keys_safe: pane never quiesced ($sess)" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ "$_q_rc" != "2" ]; then
|
||||
deadline=$(( $(date +%s) + ${SKS_DIALOG_TIMEOUT:-30} ))
|
||||
while _pane_dialog_open "$sess"; do
|
||||
if [ "${SKS_DIALOG_ESCAPE:-0}" = "1" ]; then
|
||||
_sks_herdr send-keys -t "$sess" Escape
|
||||
sleep 1
|
||||
fi
|
||||
if [ "$(date +%s)" -ge "$deadline" ]; then
|
||||
echo "send_keys_safe: dialog blocking input ($sess)" >&2
|
||||
return 2
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
fi
|
||||
|
||||
local agent_target
|
||||
agent_target=$(_sanitize_herdr_agent_name "$sess")
|
||||
# Native herdr 0.8+ fast path: agent prompt handles atomic text + enter submission
|
||||
# Gated behind quiescence and dialog checks; returns 0 on RPC success to prevent duplicate input
|
||||
if _sks_herdr agent prompt "$agent_target" "$text" >/dev/null 2>&1 || _sks_herdr agent prompt "$sess" "$text" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Fallback: paste buffer submission. Compute verification markers on demand.
|
||||
local marker marker_norm
|
||||
# Verification token: last 24 *characters* (not bytes — `tail -c` can split a
|
||||
# multi-byte UTF-8 char, e.g. Korean, producing a marker that can never match
|
||||
# the properly-decoded rendered pane text) of the last non-empty line.
|
||||
@@ -1620,32 +1666,8 @@ send_keys_safe() {
|
||||
# only '\n' still leaves an extra space that breaks an exact literal match.
|
||||
# Matching with all whitespace collapsed out sidesteps wrap formatting
|
||||
# entirely, whatever shape it takes.
|
||||
local marker_norm
|
||||
marker_norm=$(printf '%s' "$marker" | tr -d '[:space:]')
|
||||
|
||||
_pane_quiescent "$sess" || { echo "send_keys_safe: pane never quiesced ($sess)" >&2; return 1; }
|
||||
|
||||
deadline=$(( $(date +%s) + ${SKS_DIALOG_TIMEOUT:-30} ))
|
||||
while _pane_dialog_open "$sess"; do
|
||||
if [ "${SKS_DIALOG_ESCAPE:-0}" = "1" ]; then
|
||||
_sks_herdr send-keys -t "$sess" Escape
|
||||
sleep 1
|
||||
fi
|
||||
if [ "$(date +%s)" -ge "$deadline" ]; then
|
||||
echo "send_keys_safe: dialog blocking input ($sess)" >&2
|
||||
return 2
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
local agent_target
|
||||
agent_target=$(_sanitize_herdr_agent_name "$sess")
|
||||
# Native herdr 0.8+ fast path: agent prompt handles atomic text + enter submission
|
||||
# Gated behind quiescence and dialog checks; returns 0 on RPC success to prevent duplicate input
|
||||
if _sks_herdr agent prompt "$agent_target" "$text" >/dev/null 2>&1 || _sks_herdr agent prompt "$sess" "$text" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
local sks_buf="sks_${sess}_${job_id}_$$_${RANDOM}_$(date +%s%N 2>/dev/null || date +%s)"
|
||||
_sks_herdr set-buffer -b "$sks_buf" "$text"
|
||||
_sks_herdr paste-buffer -b "$sks_buf" -t "$sess"
|
||||
|
||||
@@ -92,6 +92,8 @@ def compute_2xk_layout(
|
||||
return LayoutDecision(target_pane_id=p.pane_id, direction="right", reason="single_pane_height_constrained")
|
||||
elif p.width > 0 and p.width // 2 < min_cols:
|
||||
return LayoutDecision(target_pane_id=p.pane_id, direction="overflow", is_overflow=True, reason="single_pane_overflow")
|
||||
else:
|
||||
return LayoutDecision(target_pane_id=p.pane_id, direction="right", reason="single_pane_height_constrained_unknown_width")
|
||||
return LayoutDecision(target_pane_id=p.pane_id, direction="down", reason="single_pane_split_down")
|
||||
|
||||
# Check for Headless mode: all panes have width <= 0 or height <= 0
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SKILLS_DIR="$(cd "$SCRIPT_DIR/../.." 2>/dev/null || pwd)"
|
||||
SKILLS_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
LIB_SH="$SKILLS_DIR/lib.sh"
|
||||
[ -f "$LIB_SH" ] || LIB_SH="${WORKSPACE_ROOT:-$PWD}/.agents/skills/lib.sh"
|
||||
source "$LIB_SH"
|
||||
@@ -326,10 +326,11 @@ yaml_path = os.environ['YAML_PATH']
|
||||
home = os.environ['HOME_DIR']
|
||||
skills_dir = os.environ.get('SKILLS_DIR', '')
|
||||
if not skills_dir:
|
||||
_ws_root = os.environ.get('WORKSPACE_ROOT')
|
||||
if not _ws_root:
|
||||
_ws_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '../../../..'))
|
||||
skills_dir = os.path.join(_ws_root, '.agents/skills')
|
||||
_ws_root = os.environ.get('WORKSPACE_ROOT', '')
|
||||
if _ws_root:
|
||||
skills_dir = os.path.join(_ws_root, '.agents/skills')
|
||||
else:
|
||||
skills_dir = ''
|
||||
claude_project_dir = os.environ.get('CLAUDE_PROJECT_DIR', f"{home}/.claude/projects")
|
||||
|
||||
now_iso = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||
@@ -339,12 +340,16 @@ now_iso = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||
# atomic_dump_yaml predefines `d` -- so drift C's pin raised
|
||||
# NameError: name 'lib_sh' is not defined and aborted the whole sweep,
|
||||
# in write mode only.
|
||||
lib_sh = os.environ.get('LIB_SH')
|
||||
lib_sh = os.environ.get('LIB_SH', '')
|
||||
if not lib_sh:
|
||||
_ws_root = os.environ.get('WORKSPACE_ROOT')
|
||||
if not _ws_root:
|
||||
_ws_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '../../../..'))
|
||||
lib_sh = os.path.join(_ws_root, '.agents/skills/lib.sh')
|
||||
if skills_dir:
|
||||
lib_sh = os.path.join(skills_dir, 'lib.sh')
|
||||
else:
|
||||
_ws_root = os.environ.get('WORKSPACE_ROOT', '')
|
||||
if _ws_root:
|
||||
lib_sh = os.path.join(_ws_root, '.agents/skills/lib.sh')
|
||||
else:
|
||||
lib_sh = ''
|
||||
|
||||
try:
|
||||
d
|
||||
|
||||
@@ -116,6 +116,18 @@
|
||||
#default: <cwd>/.mam/delegate_job_logs
|
||||
# DELEGATE_JOB_LOGS_DIR=/path/to/workspace/.mam/delegate_job_logs
|
||||
|
||||
# Max attempts to poll for pane renderer quiescence in send_keys_safe.
|
||||
#default: 20
|
||||
# SKS_QUIESCENT_TRIES=20
|
||||
|
||||
# Interval (seconds) between pane quiescence capture polls.
|
||||
#default: 0.5
|
||||
# SKS_QUIESCENT_INTERVAL=0.5
|
||||
|
||||
# Consecutive empty captures to conclude unobservable/headless mode early.
|
||||
#default: 3
|
||||
# SKS_EMPTY_GIVEUP=3
|
||||
|
||||
# ==============================================================================
|
||||
# deploy / distribution source (for forks/mirrors)
|
||||
# ==============================================================================
|
||||
|
||||
+6
-6
@@ -38,13 +38,13 @@
|
||||
|
||||
### **B-19 (✅ 완료 — 헤드리스 분할 레이아웃 0×0 예외 처리, reconcile SKILLS_DIR 누락 및 Fast-path 게이팅 보완)**
|
||||
- **현상**:
|
||||
1. `lib.sh:449` 헤드리스 환경에서 `herdr pane layout`이 `0×0`을 반환할 때 `overflow`로 오판정되어 새 워크스페이스(`w1, w2, w3`)가 계속 증식하던 결함.
|
||||
2. `reconcile.sh:814, 816`에서 `SKILLS_DIR`을 전달하지 않아 Python 내에서 상대 경로 조립 실패(`resume dry-run failed: No such file or directory`)가 기록되던 결함.
|
||||
3. `lib.sh:1667` `send_keys_safe`에서 `herdr agent prompt` Fast-path가 렌더러 안정화 및 다이얼로그 체크 이전에 실행되거나 실패 시 중복 입력이 발생할 수 있던 결함.
|
||||
1. `lib.sh` 헤드리스 환경에서 `herdr pane layout`이 `0×0`을 반환할 때 `overflow`로 오판정되어 새 워크스페이스(`w1, w2, w3`)가 계속 증식하던 결함 (후속 B-20 2×K 그리드 엔진으로 완전 승계 및 공용화).
|
||||
2. `reconcile.sh:19`에서 `SKILLS_DIR` 명령 치환 오류(`2>/dev/null || pwd`)로 빈 문자열이 되어 Python 내 상대 경로 조립 실패(`resume dry-run failed: No such file or directory`)가 유발되던 결함.
|
||||
3. `lib.sh:1620` `send_keys_safe`에서 `herdr agent prompt` Fast-path가 다이얼로그 체크 없이 실행되거나 헤드리스/비표시 상태에서 정숙성 루프가 불필요하게 10초 대기/실패하던 결함.
|
||||
- **조치 결과 (완료)**:
|
||||
- `lib.sh`: `w <= 0 or h <= 0`인 헤드리스 상태일 때 기본 `'right'` 분할 적용. `send_keys_safe`의 Fast-path를 안정화/다이얼로그 확인 후로 배치하고 단일 성공 즉시 `return 0` 처리.
|
||||
- `reconcile.sh`: `env_python` 및 `atomic_dump_yaml` 실행 시 `SKILLS_DIR="$SKILLS_DIR"` 명시 주입 및 3중 fallback 경로 추가.
|
||||
- 회귀 가드: `tests/test_b19_headless_reconcile_fixes.py` (4개 테스트 신규 작성 및 100% 통과).
|
||||
- `lib.sh`: B-20 공용 엔진을 통해 헤드리스 0×0 결정론적 분할 적용. `_pane_quiescent`에 `SKS_EMPTY_GIVEUP`(기본 3회) 연속 공백 감지 시 조기 `rc=2`(관측 불가, ~1.5초 소요) 탈출을 도입하고, 관측 가능한 페인은 20×0.5s(10초) 정숙성 윈도를 보존. `send_keys_safe`가 `rc=2`일 때 시각 다이얼로그 루프를 건너뛰고 RPC Fast-path로 직행하도록 최적화. RPC 성공 즉시 `return 0` 반환하여 중복 입력 방지 및 온디맨드 마커 계산 적용.
|
||||
- `reconcile.sh`: `SKILLS_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"`로 절대 경로 즉시 계산 및 `env_python`/`atomic_dump_yaml`로 명시 주입, Python 측 `__file__` 의존성 제거.
|
||||
- 회귀 가드: `tests/test_b19_headless_reconcile_fixes.py` (6개 기능/통합 테스트 100% 통과).
|
||||
|
||||
### **B-14 (✅ 완료 — F-1 / P1): `publish_event.py` 브로커 장애 시 `return 2` 조기 탈출로 인한 65분 루프 정지**
|
||||
- **현상**: `publish_event.py`에서 브로커 네트워크 장애 발생 시 `return 2`로 조기 종료되어, 뒤따르는 로컬 레지스트리 상태(`update_job_status(status=completed)`) 및 감사 로그(`append_event`, `registry.append_event`) 갱신이 누락되던 결함.
|
||||
|
||||
+1
-1
@@ -130,7 +130,7 @@ $ bash .agents/skills/multi-agent-mux-orc-onboard/scripts/orc_onboard.sh --remov
|
||||
### 7) 전용 NATS 메시징 브로커 설정 (.mam.env)
|
||||
MAM은 비동기 작업 위임(`multi-agent-mux-delegate-job`) 및 이벤트 스트림 중계를 위해 MQTT 3.1.1 및 JetStream 기반의 사설 NATS 브로커(`nats-docker`)를 표준으로 지원합니다.
|
||||
* **환경 설정 생성**: `bash deploy/generate-env.sh` (또는 `cp .mam.env.example .mam.env`)를 실행하여 로컬 `.mam.env`를 생성합니다.
|
||||
* **서브모듈 동기화**: `git submodule update --init --recursive` 명령어로 `nats-docker/` 배포 자산을 초기화합니다.
|
||||
* **서브모듈 동기화**: `git submodule update --init --recursive` 명령어로 `nats-docker/` 배포 자산을 초기화합니다. (사내 비공개 저장소 `laa/nats-docker` 접근 권한이 없는 경우 서브모듈 동기화를 생략해도 표준 MQTT 브로커를 통해 기본 프레임워크 기능이 완비됩니다.)
|
||||
* **사설 서버 배포 가이드**: 자세한 도커 배포 및 Tailscale 연동 절차는 [`nats-docker/PRIVATE_SERVER.md`](../nats-docker/PRIVATE_SERVER.md) 및 [`MESSAGING.md`](../MESSAGING.md)를 참조하십시오.
|
||||
|
||||
---
|
||||
|
||||
+4
-1
@@ -72,13 +72,16 @@ To register these skills globally or for a specific workspace:
|
||||
For production deployments and private networks, MAM utilizes a dedicated NATS broker (`nats:2.12-alpine` with MQTT 3.1.1 and JetStream enabled). The container assets and deployment guides are managed in the `nats-docker` submodule:
|
||||
|
||||
```bash
|
||||
# When cloning the repository, initialize submodules:
|
||||
# When cloning the repository with internal credentials:
|
||||
git clone --recurse-submodules https://git.godopu.com/tmpl/multi-agent-mux.git
|
||||
|
||||
# Or initialize submodules in an existing clone:
|
||||
git submodule update --init --recursive
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> `nats-docker` is an optional submodule hosted in the private repository `laa/nats-docker`. If cloning without internal credentials, omit `--recurse-submodules`. The MAM framework functions out-of-the-box using standard MQTT brokers configured in `.mam.env`.
|
||||
|
||||
Refer to [`nats-docker/PRIVATE_SERVER.md`](../nats-docker/PRIVATE_SERVER.md) and [`MESSAGING.md`](../MESSAGING.md) for detailed configuration, `.mam.env` generation, and security guidelines.
|
||||
|
||||
---
|
||||
|
||||
@@ -4,60 +4,38 @@ import json
|
||||
import subprocess
|
||||
import pytest
|
||||
|
||||
from lib_py.layout import compute_2xk_layout
|
||||
|
||||
|
||||
def test_bug2_headless_layout_does_not_overflow():
|
||||
"""Verify Bug 2: w=0, h=0 in headless mode outputs 'right' (not 'overflow')."""
|
||||
calc_script = """
|
||||
import sys, json, os
|
||||
min_cols = int(os.environ.get('MAM_MIN_COLS', 60))
|
||||
min_rows = int(os.environ.get('MAM_MIN_ROWS', 20))
|
||||
try:
|
||||
d = json.loads(sys.stdin.read()).get('result', {})
|
||||
focused_id = d.get('focused_pane_id', '')
|
||||
panes = d.get('panes', [])
|
||||
anchor = None
|
||||
for p in panes:
|
||||
if p.get('pane_id') == focused_id:
|
||||
anchor = p.get('rect', {})
|
||||
break
|
||||
if not anchor and panes:
|
||||
anchor = panes[0].get('rect', {})
|
||||
if anchor:
|
||||
w = anchor.get('width', 0)
|
||||
h = anchor.get('height', 0)
|
||||
if w <= 0 or h <= 0:
|
||||
print('right')
|
||||
elif w // 2 >= min_cols:
|
||||
print('right')
|
||||
elif h // 2 >= min_rows:
|
||||
print('down')
|
||||
else:
|
||||
print('overflow')
|
||||
except Exception:
|
||||
pass
|
||||
"""
|
||||
"""Verify Bug 2: w=0, h=0 in headless mode does not trigger overflow."""
|
||||
# 1. Headless 0x0
|
||||
payload_0x0 = json.dumps({"result": {"panes": [{"rect": {"width": 0, "height": 0}}]}})
|
||||
res = subprocess.run([sys.executable, "-c", calc_script], input=payload_0x0, capture_output=True, text=True)
|
||||
assert res.stdout.strip() == "right", f"Headless 0x0 should default to 'right', got {res.stdout.strip()}"
|
||||
payload_0x0 = {"result": {"panes": [{"pane_id": "p1", "rect": {"width": 0, "height": 0}}]}}
|
||||
d_0x0 = compute_2xk_layout(payload_0x0)
|
||||
assert not d_0x0.is_overflow, f"Headless 0x0 should not overflow, got {d_0x0}"
|
||||
assert d_0x0.direction in ("right", "down"), f"Headless 0x0 direction must be right or down, got {d_0x0.direction}"
|
||||
|
||||
# 2. Genuine small pane (overflow)
|
||||
payload_small = json.dumps({"result": {"panes": [{"rect": {"width": 50, "height": 30}}]}})
|
||||
res = subprocess.run([sys.executable, "-c", calc_script], input=payload_small, capture_output=True, text=True)
|
||||
assert res.stdout.strip() == "overflow", f"Small pane should be 'overflow', got {res.stdout.strip()}"
|
||||
payload_small = {"result": {"panes": [{"pane_id": "p1", "rect": {"width": 50, "height": 30}}]}}
|
||||
d_small = compute_2xk_layout(payload_small)
|
||||
assert d_small.is_overflow, f"Small pane should be overflow, got {d_small}"
|
||||
assert d_small.direction == "overflow"
|
||||
|
||||
# 3. Wide pane (split right)
|
||||
payload_wide = json.dumps({"result": {"panes": [{"rect": {"width": 160, "height": 30}}]}})
|
||||
res = subprocess.run([sys.executable, "-c", calc_script], input=payload_wide, capture_output=True, text=True)
|
||||
assert res.stdout.strip() == "right", f"Wide pane should be 'right', got {res.stdout.strip()}"
|
||||
payload_wide = {"result": {"panes": [{"pane_id": "p1", "rect": {"width": 160, "height": 30}}]}}
|
||||
d_wide = compute_2xk_layout(payload_wide)
|
||||
assert not d_wide.is_overflow
|
||||
assert d_wide.direction == "right"
|
||||
|
||||
# 4. Tall pane (split down)
|
||||
payload_tall = json.dumps({"result": {"panes": [{"rect": {"width": 80, "height": 60}}]}})
|
||||
res = subprocess.run([sys.executable, "-c", calc_script], input=payload_tall, capture_output=True, text=True)
|
||||
assert res.stdout.strip() == "down", f"Tall pane should be 'down', got {res.stdout.strip()}"
|
||||
payload_tall = {"result": {"panes": [{"pane_id": "p1", "rect": {"width": 80, "height": 60}}]}}
|
||||
d_tall = compute_2xk_layout(payload_tall)
|
||||
assert not d_tall.is_overflow
|
||||
assert d_tall.direction == "down"
|
||||
|
||||
|
||||
def test_bug3_reconcile_skills_dir_passed_and_fallback():
|
||||
"""Verify Bug 3: reconcile.sh passes SKILLS_DIR to env_python/atomic_dump_yaml and RECON_SRC has fallback."""
|
||||
"""Verify Bug 3: reconcile.sh evaluates valid SKILLS_DIR and passes it to Python subshells."""
|
||||
recon_path = os.path.abspath(".agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh")
|
||||
with open(recon_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
@@ -66,9 +44,32 @@ def test_bug3_reconcile_skills_dir_passed_and_fallback():
|
||||
assert 'SKILLS_DIR="$SKILLS_DIR" LIB_SH="$LIB_SH" env_python' in content
|
||||
assert 'SKILLS_DIR="$SKILLS_DIR" LIB_SH="$LIB_SH" atomic_dump_yaml' in content
|
||||
|
||||
# Assert fallback exists inside RECON_SRC
|
||||
assert "if not skills_dir:" in content
|
||||
assert "skills_dir = os.path.join(_ws_root, '.agents/skills')" in content
|
||||
# Read the actual line from reconcile.sh and verify it uses && pwd instead of || pwd
|
||||
line19 = next(l for l in content.splitlines() if l.startswith("SKILLS_DIR="))
|
||||
assert "&& pwd" in line19 and "|| pwd" not in line19, f"Invalid SKILLS_DIR evaluation: {line19}"
|
||||
|
||||
# Functionally evaluate that exact line from reconcile.sh in bash
|
||||
script = f"""#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$(dirname "{recon_path}")"
|
||||
{line19}
|
||||
echo "RESOLVED_SKILLS_DIR=$SKILLS_DIR"
|
||||
if [ -z "$SKILLS_DIR" ]; then
|
||||
echo "ERROR: SKILLS_DIR is empty" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -d "$SKILLS_DIR" ]; then
|
||||
echo "ERROR: directory does not exist" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f "$SKILLS_DIR/lib.sh" ]; then
|
||||
echo "ERROR: lib.sh missing" >&2
|
||||
exit 1
|
||||
fi
|
||||
"""
|
||||
res = subprocess.run(["bash", "-c", script], capture_output=True, text=True)
|
||||
assert res.returncode == 0, f"Script failed: {res.stderr}"
|
||||
assert "RESOLVED_SKILLS_DIR=" in res.stdout
|
||||
|
||||
|
||||
def test_bug4_send_keys_safe_gating_order():
|
||||
@@ -89,7 +90,7 @@ def test_bug4_send_keys_safe_gating_order():
|
||||
assert dialog_idx != -1, "_pane_dialog_open not found in send_keys_safe"
|
||||
assert prompt_idx != -1, "agent prompt not found in send_keys_safe"
|
||||
|
||||
# Crucial ordering check: quiescence and dialog checks MUST precede agent prompt
|
||||
# Ordering check: quiescence and dialog checks MUST precede agent prompt
|
||||
assert quiescent_idx < prompt_idx, "_pane_quiescent must execute before agent prompt fast-path"
|
||||
assert dialog_idx < prompt_idx, "_pane_dialog_open must execute before agent prompt fast-path"
|
||||
|
||||
@@ -126,3 +127,108 @@ echo "SUCCESS"
|
||||
assert res.returncode == 0, f"Expected clean exit 0 without duplicate paste-buffer call, got {res.returncode}. Stderr: {res.stderr}"
|
||||
assert "SUCCESS" in res.stdout
|
||||
|
||||
|
||||
def test_bug4_headless_unobservable_fast_path(tmp_path):
|
||||
"""Verify Bug 4 / R-1: in headless mode where capture-pane is empty, send_keys_safe bypasses dialogs and succeeds immediately via RPC fast-path."""
|
||||
test_script = f"""#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
SKILL_DIR="{os.path.abspath('.agents/skills')}"
|
||||
source "$SKILL_DIR/lib.sh"
|
||||
|
||||
PROMPT_CALLED=0
|
||||
PASTE_CALLED=0
|
||||
_sks_herdr() {{
|
||||
if [ "${{1:-}}" = "capture-pane" ]; then
|
||||
# Headless / unobservable pane returns empty output
|
||||
echo ""
|
||||
return 0
|
||||
fi
|
||||
if [ "${{1:-}}" = "agent" ] && [ "${{2:-}}" = "prompt" ]; then
|
||||
PROMPT_CALLED=1
|
||||
return 0
|
||||
fi
|
||||
if [ "${{1:-}}" = "paste-buffer" ]; then
|
||||
PASTE_CALLED=1
|
||||
return 0
|
||||
fi
|
||||
return 0
|
||||
}}
|
||||
|
||||
# Run send_keys_safe without stubbing _pane_quiescent
|
||||
send_keys_safe "headless-sess" "my prompt" "job-headless"
|
||||
if [ "$PROMPT_CALLED" != "1" ]; then
|
||||
echo "ERROR: agent prompt was not called in headless mode" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$PASTE_CALLED" = "1" ]; then
|
||||
echo "ERROR: paste-buffer was called unexpectedly" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "HEADLESS_OK"
|
||||
"""
|
||||
res = subprocess.run(["bash", "-c", test_script], capture_output=True, text=True)
|
||||
assert res.returncode == 0, f"Headless send_keys_safe failed: {res.stderr}"
|
||||
assert "HEADLESS_OK" in res.stdout
|
||||
|
||||
|
||||
def test_bug4_slow_settling_pane_success(tmp_path):
|
||||
"""Verify N-1 / G-2: a pane that takes 3 seconds of changing output to settle stabilizes cleanly and executes RPC prompt."""
|
||||
count_file = str(tmp_path / "capture_count.txt")
|
||||
with open(count_file, "w") as f:
|
||||
f.write("0")
|
||||
|
||||
prompt_flag = str(tmp_path / "prompt_called.txt")
|
||||
paste_flag = str(tmp_path / "paste_called.txt")
|
||||
|
||||
test_script = f"""#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
SKILL_DIR="{os.path.abspath('.agents/skills')}"
|
||||
source "$SKILL_DIR/lib.sh"
|
||||
|
||||
COUNT_FILE="{count_file}"
|
||||
PROMPT_FLAG="{prompt_flag}"
|
||||
PASTE_FLAG="{paste_flag}"
|
||||
|
||||
_sks_herdr() {{
|
||||
if [ "${{1:-}}" = "capture-pane" ]; then
|
||||
local c
|
||||
c=$(cat "$COUNT_FILE" 2>/dev/null || echo "0")
|
||||
c=$((c + 1))
|
||||
echo "$c" > "$COUNT_FILE"
|
||||
# Change for first 5 captures (2.5s), then stabilize
|
||||
if [ "$c" -le 5 ]; then
|
||||
echo "Rendering frame $c..."
|
||||
else
|
||||
echo "Stable Idle Screen"
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
if [ "${{1:-}}" = "agent" ] && [ "${{2:-}}" = "prompt" ]; then
|
||||
touch "$PROMPT_FLAG"
|
||||
return 0
|
||||
fi
|
||||
if [ "${{1:-}}" = "paste-buffer" ]; then
|
||||
touch "$PASTE_FLAG"
|
||||
return 0
|
||||
fi
|
||||
return 0
|
||||
}}
|
||||
|
||||
# Run send_keys_safe on slow-settling pane with default 20x0.5 window
|
||||
send_keys_safe "slow-sess" "my prompt" "job-slow"
|
||||
if [ ! -f "$PROMPT_FLAG" ]; then
|
||||
echo "ERROR: agent prompt was not called on slow-settling pane" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -f "$PASTE_FLAG" ]; then
|
||||
echo "ERROR: paste-buffer was called unexpectedly" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "SLOW_SETTLE_OK"
|
||||
"""
|
||||
res = subprocess.run(["bash", "-c", test_script], capture_output=True, text=True)
|
||||
assert res.returncode == 0, f"Slow settling pane failed: {res.stderr}"
|
||||
assert "SLOW_SETTLE_OK" in res.stdout
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user