# B-4 구현 계획서 Rev.2 — `session_created` 를 실제 POSIX 시각으로 - **Job**: `544ae416` (Rev.1 = `94687096`, Challenge = `7f69fd70` / agy) - **Role**: Planner (설계 산출물만 작성. 구현·커밋은 Creator/GM 소관 — `MULTI_AGENT_RULES.md` §1) - **작성일**: 2026-08-08 - **검증 트리**: `scratchpad/b4base`(clean HEAD) · `b4fix`(Rev.1) · `rev2`(Rev.2) · `b4mut`/`b4mut2`(변이) - **결과**: 테스트 21건 — HEAD 2/21, Rev.1 18/21, **Rev.2 21/21** --- ## 0. 판정 요약 agy 의 지적 3건을 각각 실측으로 판정했다. **전부 채택하되, 셋 다 제시된 메커니즘은 틀렸다.** 그리고 조사 과정에서 양쪽 모두 보지 못한 결함 하나를 찾았는데, 그것이 이 논쟁에서 가장 중요한 항목이다. | | agy 의 주장 | 판정 | 실측 결과 | |---|---|---|---| | **C-1** | F1 적용 시 resume 한 트랜스크립트가 **무조건 거부**된다 | **메커니즘 기각 / 우려는 채택** | resume 경로는 3개 트리 모두에서 **정상 해결**된다. 주장의 3단계(`resume 가 T_resume 를 기록`)는 **일어나지 않는다** — 실측 | | **C-2** | `ls` 1회당 `4N+2` 포크, batch 처리 필요 | **채택** | Rev.1 은 호출당 **156.0 ms**(HEAD 42.6 ms). 단일 파이썬으로 **72.7 ms** — 53% 감소 | | **C-3** | `ps` / `mktime` 의 `TZ` 의존으로 수 시간 스큐 | **메커니즘 기각 / 조치는 채택** | 동일 호출 내에서 둘은 **모든 TZ 에서 정확히 일치**한다(4종 실측, epoch 동일). 다만 `TZ=UTC` 고정은 별개 이유로 이득이 있어 채택 | | **신규** | — | **차단(Blocker)** | **`reconcile.sh` 쓰기 경로가 drift-C 진입 시 `NameError` 로 죽는다.** HEAD 에서 rc=1. C-1 이 지키려던 그 경로가 이미 죽어 있었다 | 추가 변경은 4건(F5–F8)이고, Rev.1 의 F1–F4 는 유지된다. | | 대상 | 내용 | |---|---|---| | **F5** | `reconcile.sh` | `lib_sh` 무조건 바인딩 — drift-C 쓰기 경로 `NameError` 수정 (**차단**) | | **F6** | `lib.sh::verify_session_uuid` | 트랜스크립트 하한을 `discover` 모드로 한정 (agy 제언 1) | | **F7** | `lib.sh` 시프트 `ls` | 단일 파이썬 프로세스 + `ps` 일괄 조회 (agy 제언 2) | | **F8** | `lib.sh` 시프트 `ls` | `TZ=UTC` + `LC_ALL=C` 고정 (agy 제언 3) | --- ## 1. C-1 — 재현되지 않는다. 다만 그 자리에 다른 것이 있었다 ### 1.1 주장의 3단계는 일어나지 않는다 agy 는 "`resume_session.sh` 가 신규 herdr 세션을 만들고 그 `shell_pid` 시작시각($T_{resume}$)이 `herdr_session_epoch` 가 된다"고 했다. 이것이 성립해야 나머지 논증이 따라온다. **실측했다** — 원래 epoch 를 심어 둔 행에 대해 `update_yaml_resumed.sh` 를 실행하고 값을 다시 읽었다. ``` before resume : herdr_session_epoch = 1786100000 after resume : herdr_session_epoch = 1786100000 (now = 1786197485) status=running own_id=aaaaaaaa-... -> resume LEAVES the original creation epoch in place. ``` `update_yaml_resumed.sh` 는 `herdr_session_epoch` 를 **기존 행이 있으면 건드리지 않는다.** `NOW_EPOCH` 는 행이 아예 없을 때(`target is None`) 새로 만드는 분기에서만 쓰인다 — 그리고 그 분기는 **HEAD 에서도 이미 `NOW_EPOCH` 를 쓰므로 F1 과 무관하다.** ### 1.2 "즉시 제거(Clear)" 도 일어나지 않는다 검증 실패 시 registry 에서 id 를 지우는 코드를 저장소 전체에서 찾았다. `*_id_own = None` 을 쓰는 곳은 `stop_session.sh`(정상 종료), `create_session.sh`·`reconcile.sh`(신규 행 초기화) 뿐이다. **검증 실패를 이유로 id 를 지우는 경로는 존재하지 않는다.** 실제 결과는 "핀이 되지 않음"이고, 에이전트가 다음에 무엇이든 쓰는 순간 mtime 이 올라가 자동 복구된다. ### 1.3 사용자에게 닿는 경로에서도 재현되지 않는다 가장 중요한 확인이다. **resume 이 대화를 잃는가?** 행 epoch = now, 대화는 2시간 전 이후 미기록, uuid 는 이미 핀된 상태 — agy 시나리오 그대로 만들어 `resolve_session_id.sh` 를 세 트리에서 돌렸다. ``` ===== b4base (HEAD) ===== -> resolve_session_id RESOLVED the pinned conversation ===== b4fix (Rev.1) ===== -> resolve_session_id RESOLVED the pinned conversation ===== rev2 (Rev.2) ===== -> resolve_session_id RESOLVED the pinned conversation ``` 세 트리 모두 대화를 되찾는다. **C-1 은 사용자에게 닿지 않는다.** 다만 *왜* 되찾는지는 따져 볼 값어치가 있다. `verify_session_uuid` 를 직접 호출해 보면: ``` b4base discover=False revalidate=False b4fix discover=False revalidate=False rev2 discover=False revalidate=True ``` Rev.1 에서 **행 기반 검사는 실제로 거부한다.** 그런데도 resume 이 성공하는 이유는 `find_workspace_uuid` 가 그 뒤에 **행 없이(`row=None`) 디스크를 스캔하는 폴백**을 돌리기 때문이다. 행이 없으면 `epoch = 0` 이라 하한이 아예 적용되지 않고, 같은 파일이 그대로 통과한다. 즉 **agy 의 우려는 방향이 맞다** — 행 기반 revalidate 는 정말로 거부한다. 다만 그 거부가 사용자에게 보이지 않는 것은 하한을 우회하는 폴백이 뒤를 받쳐 주기 때문이고, 그 폴백은 동시에 B-4 가 세우려는 가드를 약화시키는 지점이기도 하다. 이 의존을 없애는 것이 **F6** 이다(§2.2). ### 1.4 실제로 있던 것 — drift-C 쓰기 경로가 죽어 있다 C-1 을 재현하려고 `reconcile.sh` 를 돌리다 발견했다. 조건을 통제해 epoch 만 바꿔 가며 측정한 결과다(같은 트리, 같은 낡은 트랜스크립트): ``` epoch = 999999 (HEAD sentinel) transcript mtime = now-7200 dry-run rc=0 drifts=['C-warn'] write rc=1 <- NameError: name 'lib_sh' is not defined epoch = now (F1 real epoch) transcript mtime = now-7200 dry-run rc=0 drifts=[] write rc=0 ``` 원인은 `reconcile.sh:331-347` 이다. ```python try: d except NameError: import subprocess d = {} try: lib_sh = os.environ.get('LIB_SH') # <- 이 분기 안에서만 바인딩된다 ... ``` `lib_sh` 는 `except NameError:` 블록 안에서만 대입된다. 그런데 **쓰기 경로는 그 블록에 들어가지 않는다** — `atomic_dump_yaml` 이 mutation 을 exec 하기 전에 `d` 를 미리 정의하기 때문이다(`lib.sh` 의 해당 함수 내부). 반면 dry-run 경로(`env_python`)는 `d` 를 정의하지 않으므로 블록에 들어가 `lib_sh` 가 바인딩된다. 결과: **drift-C 가 후보를 하나 찾아 핀 단계(`verify_tui_viewport` 명령 문자열 조립)에 도달하는 순간, 쓰기 모드에서만 `NameError` 로 스윕 전체가 중단된다.** 모니터가 실제로 도는 모드가 정확히 그 모드다. 이 사실이 C-1 논쟁을 재정의한다. - agy 는 "F1 이 정상 트랜스크립트 채택을 막는다"고 했다. 그런데 **HEAD 에서의 대안은 '정상 채택'이 아니라 '`rc=1` 로 죽음'이었다.** - 그리고 F1 은 후보를 비워 그 크래시를 **우연히 가린다**(위 표의 두 번째 블록이 `rc=0`). 고장이 사라진 게 아니라 도달하지 못하게 된 것이다. 가려진 결함은 고쳐진 결함이 아니다. **F5 는 차단 항목이다.** 이것 없이는 C-1 을 논할 무대 자체가 성립하지 않는다. --- ## 2. 변경 계획 (Rev.1 F1–F4 + 신규 F5–F8) Rev.1 의 F1(시프트 `ls` 파생), F2(reconcile 위생), F3(mock), F4(tier3 단언)는 **그대로 유지된다.** 상세는 Rev.1 §2 를 참조하고, 아래는 Rev.2 에서 더해지거나 바뀌는 부분만 적는다. ### F5 — `reconcile.sh`: `lib_sh` 무조건 바인딩 (차단) `try: d / except NameError:` **바깥**에서 바인딩한다. 기존 분기 내부의 대입은 제거한다. ```python now_iso = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ') # Bound unconditionally. This used to be assigned only inside the "except # NameError" branch below, which the write path never enters because # 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') 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') try: d except NameError: import subprocess d = {} try: script = f"source '{lib_sh}' && load_state_json" ... ``` 검증 후 `rc=1 → rc=0`, `NameError` 소멸(§4 B-11). ### F6 — `verify_session_uuid`: 하한을 `discover` 로 한정 (agy 제언 1 채택) ```python row = row or {} # The floor answers "could this transcript belong to a PREVIOUS incarnation # of this session?", which only matters while picking an unknown uuid off # disk. In "revalidate" the uuid is one this row already recorded, and a # session resumed but not yet written to legitimately has a transcript # older than its current process -- applying the floor there discards the # very conversation the resume was for. epoch = row.get("herdr_session_epoch", 0) if mode == "discover" else 0 ``` **agy 의 제언을 채택하되 근거를 바꾼다.** 이 변경은 §1.3 에서 보았듯 **사용자에게 보이는 동작을 바꾸지 않는다** — 지금은 하한 우회 폴백이 뒤를 받치고 있기 때문이다. 채택하는 이유는 두 가지다. 1. `revalidate` 라는 이름이 약속하는 계약을 코드가 지키게 된다. 이미 이 행이 기록한 id 를 재확인하는 자리이고, `sessionId`·`cwd` 대조는 그대로 남는다. 2. resume 의 성공이 **하한을 우회하는 폴백에 의존하지 않게 된다.** 그 폴백은 B-4 가드를 약화시키는 지점이기도 해서 언젠가 조여야 하는데(§6.1), F6 없이 조이면 resume 이 조용히 깨진다. `discover` 에는 하한이 그대로 남으므로 B-4 의 보호는 유지된다(§4 B-9, B-12 가 양쪽을 함께 고정한다). **하지 않은 것**: agy 는 `last_visible_status` / `resumed` 필드를 검증 조건에 넣자고도 제안했다. 넣지 않았다. 그 필드들은 문자열 서술이고 여러 곳에서 자유롭게 덮어써지므로 가드의 입력으로 삼으면 결합이 늘고 판정이 취약해진다. `mode` 는 이미 호출자가 의도를 선언하는 정식 파라미터이고, 필요한 구분을 정확히 담고 있다. ### F7 — 시프트 `ls`: 단일 파이썬 + `ps` 일괄 조회 (agy 제언 2 채택) Rev.1 은 bash `while read` 루프 안에서 에이전트마다 `herdr` / `python3` / `ps` / `python3` 를 포크했다. Rev.2 는 **파이썬 한 프로세스**가 `_real_herdr` 의 세션 인자 처리를 재현해 `pane process-info` 를 직접 호출하고, 모든 pid 를 **`ps` 한 번**으로 조회한다. 포크 수: `4N + 3` → **`N + 3`**. 실측(라이브 에이전트 3개, 20회 평균): ``` HEAD (constant, no work) 42.6 ms/call Rev.1 F1 (shell loop, ~4N+3 forks) 156.0 ms/call Rev.2 F7 (single python, ~N+3) 72.7 ms/call ``` heartbeat 주기는 `RECONCILE_POLL_INTERVAL` 기본 **15초**다. Rev.1 은 주기당 약 113 ms 를 더 쓰고, Rev.2 는 30 ms 로 줄인다. 출력은 Rev.1 과 문자 단위로 동일하다(§4 B-1..B-6 이 rev2 에서 그대로 통과). **함께 넣는 에러 경로 강화 — 이건 범위 확장이므로 명시한다.** `agent list` 를 파싱하지 못했을 때 Rev.1 은 `sys.exit(0)` 로 조용히 빈 출력을 냈다. `reconcile.sh:382-388` 은 **rc=0 + 빈 stdout 을 "herdr 확인됨, 세션 0개"로 읽고**, 그러면 YAML 의 살아 있는 행들이 drift A 로 terminated 처리된다. 파싱 실패는 "세션이 없다"가 아니라 "모르겠다"이므로 **nonzero 로 종료**해야 reconcile 이 `herdr_confirmed=False` 쪽으로 빠진다. 한 줄짜리 변경이지만 결과 차이가 커서 넣었고, B-13 으로 고정했다. 전문은 §7.1. ### F8 — `TZ=UTC` + `LC_ALL=C` (agy 제언 3 의 조치만 채택) **제시된 메커니즘은 성립하지 않는다.** agy 는 `ps` 와 `mktime` 이 서로 다른 `TZ` 를 봐서 수 시간 스큐가 난다고 했다. 둘은 같은 호출 안에서 같은 환경을 상속하므로 다를 수 없다. 실측: ``` TZ=UTC ps='Fri Aug 7 22:34:49 2026' mktime=1786142089 TZ=America/New_York ps='Fri Aug 7 18:34:49 2026' mktime=1786142089 TZ=Asia/Seoul ps='Sat Aug 8 07:34:49 2026' mktime=1786142089 TZ=Not/AZone ps='Fri Aug 7 22:34:49 2026' mktime=1786142089 ``` `ps` 는 확실히 `TZ` 를 따르고(시각 표기가 실제로 바뀐다), 파이썬도 같은 `TZ` 로 되읽는다. **네 경우 모두 epoch 가 완전히 동일하다.** 스큐는 없다. 그럼에도 `TZ=UTC` 는 채택한다. 이유가 다르다 — Rev.1 §3.4 에서 내가 남겨 둔 **DST 모호성**을 없애기 때문이다. `mktime` 은 로컬시각을 해석하므로 연 1회 중복되는 1시간 구간에서 최대 1시간 흔들린다. 양쪽을 UTC 로 고정하면 그 구간 자체가 사라진다. 공짜이고 엄격히 낫다. --- ## 3. 부작용 재검토 ### 3.1 F6 이 B-4 를 되돌리지 않는가 되돌리지 않는다. `discover` — 디스크에서 **모르는** uuid 를 고르는 자리 — 에는 하한이 그대로 있다. Rev.1 의 피해 재현 테스트(B-9)는 `discover` 를 쓰므로 rev2 에서도 그대로 초록이고, B-12 가 `discover=False` 와 `revalidate=True` 를 **같은 입력으로 동시에** 단언해 한쪽만 느슨해지는 변경을 막는다. ### 3.2 F5 가 감추던 크래시를 드러내지 않는가 드러낸다 — 그리고 그게 목적이다. F5 이후 drift-C 는 핀 단계까지 정상 진행하고, 뷰포트가 맞지 않으면 `C-warn` 을 남긴다(§1.4 의 dry-run 과 동일한 거동). 쓰기 경로가 dry-run 과 같은 판정을 내리는 상태가 정상이다. ### 3.3 shellcheck Rev.1 §3.3 과 동일하다. 시프트 본문은 `cat <<'EOF'` 인용 heredoc 안의 문자열이라 shellcheck 이 분석하지 않는다 — F7 의 회귀 위험은 없고, 동시에 lint 사각지대다. `bash -n` 은 두 파일 모두 통과. 로컬에 shellcheck 이 없어 **CI 잡 확인은 Creator 몫**이다. ### 3.4 F7 의 파이썬은 홑따옴표로 감싼다 Rev.1 은 `python3 -c "..."`(큰따옴표) 였으나 Rev.2 는 `python3 -c '...'` 다. 코드가 길어져 큰따옴표 이스케이프가 늘면 읽기 어려워지기 때문이다. 대신 **파이썬 본문에 `$` 를 쓰지 않는다**는 제약이 생긴다(홑따옴표 안이라 bash 확장은 없지만, 향후 수정자가 큰따옴표로 되돌릴 때 사고가 나기 쉬운 지점이다). 필요한 값은 전부 환경변수(`MAM_LS_*`)로 넘긴다. --- ## 4. 테스트 계획 `tests/test_b4_session_created.py` — Rev.1 15건 + Rev.2 6건 = **21건**. | ID | 검증 대상 | 유래 | |---|---|---| | B-1..B-6 | epoch 가 실제 시각이고, pane 루트 프로세스에서 오며, 폴백이 보수적 | Rev.1 | | B-7 ×2 | sentinel 이 워크스페이스 상태에 남지 않음 | Rev.1 | | B-8 ×5 | `ls` 필드 위생 처리 | Rev.1 | | B-9 | 가드가 낡은 트랜스크립트를 거부하고 살아 있는 것은 받아들임 | Rev.1 | | B-10 | 등록 폴백이 `0` 으로 떨어지지 않음 | Rev.1 | | **B-11** | **쓰기 경로가 drift-C 진입 시 죽지 않음** | **§1.4 (신규)** | | **B-12** | **하한이 `discover` 에만 적용됨** — 같은 입력으로 양쪽 동시 단언 | **C-1 / F6** | | **B-13** | **파싱 불가한 agent list 가 rc=0 이 아님** | **F7 강화** | | **B-14 ×3** | **caller `TZ` 가 epoch 를 움직이지 않음** | **C-3 / F8** | ### 4.1 트리별 결과 | 트리 | 결과 | 실패 항목 | |---|---|---| | `b4base` (clean HEAD) | **2 / 21** | — | | `b4fix` (Rev.1) | **18 / 21** | B-11, B-12, B-13 | | `rev2` (Rev.2) | **21 / 21** | — | Rev.1 에서 정확히 F5·F6·F7강화 세 항목만 실패한다. 즉 신규 3건이 각각 자기 변경을 변별한다. ### 4.2 공허하지 않음의 근거 - **변이 2종**(Rev.1 에서 수행, rev2 에서도 유효): `except ValueError` 제거 → B-8 2건 실패. 시프트가 `foreground_processes[0]` 사용 → B-3·B-4 실패. - HEAD 에서 통과하는 2건은 **B-4 와 B-9 로 의도된 대조군**이다. B-4 는 HEAD 에서 공허하게 통과하는 함정 탐지기이고, B-9 는 "sentinel 이면 가드가 꺼지고 실제 시각이면 켜진다"는 대조를 고정한다. ### 4.3 B-14 는 수정 탐지기가 아니다 — 명시한다 B-14 는 **Rev.1 에서도 통과한다.** §2 F8 에서 실측한 대로 스큐가 애초에 없기 때문이다. F8 이 없애는 것은 DST 모호성이고, 그건 이 테스트가 도달할 수 없는 영역이다. B-14 를 남기는 이유는 회귀 방지다 — 나중에 누가 naive `datetime` 으로 갈아타거나 `LC_ALL` 을 빼면 즉시 깨지도록. **F8 의 효과를 측정하는 테스트가 아니라는 점을 표로 감추지 않고 여기 적어 둔다.** ### 4.4 회귀 ``` b4base (clean HEAD) : 128 passed b4fix (Rev.1) : 143 passed rev2 (Rev.2) : §8.1 ``` --- ## 5. 적용 순서와 리스크 ### 순서 1. **F5** — 단독 선행. 차단 항목이고, 다른 변경과 무관하게 그 자체로 결함 수정이다. 이후 단계의 관측이 가능해진다. 2. **F1** → **F7/F8** — F7 은 F1 의 `ls` 핸들러를 다시 쓰는 것이므로 한 커밋으로 묶어도 좋다. 나누려면 반드시 F1 먼저. 3. **F2** — F1 과 독립. 4. **F6** — 단독. `discover`/`revalidate` 양쪽을 B-12 로 확인. 5. **F3** → **F4** — F3 이 선행(F4 의 단언이 mock 의 실제 시각에 의존). ### 리스크 | | 내용 | 완화 | |---|---|---| | R-1 | `ps` 부재/제한 환경 | `now` 폴백. B-5/B-6 | | R-2 | `lstart` 로케일 의존 | `LC_ALL=C`. B-14 | | R-3 | 시프트 본문 shellcheck 사각지대 | `bash -n` 통과. CI 확인은 Creator (§3.3) | | R-4 | F6 이 `discover` 까지 느슨하게 만들 위험 | B-12 가 같은 입력으로 양쪽 동시 단언 | | R-5 | F5 이후 drift-C 가 실제로 핀을 쓰기 시작 | 의도된 결과. §3.2. 첫 배포 후 `C-warn` 빈도 관찰 권고 | ### 5.1 채택하지 않은 것 - **`last_visible_status` / `resumed` 를 가드 입력으로**(agy 제언 1 의 대안) — §2.2 후단 참조. - **`herdr_session_epoch_source` 출처 필드** — Rev.1 §5.1 의 판단을 유지한다. F1 이 있으면 reconcile 폴백은 사실상 도달 불가 경로이고, 새 YAML 키는 스키마 문서·상태 병합·테스트로 파급된다. - **오염된 기존 행 마이그레이션** — 이 워크스페이스 실측 결과 `epoch < 1000000000` 인 행은 **0건**이다(Rev.1 §5.1). 관측되지 않은 상태를 위한 마이그레이션은 넣지 않는다. --- ## 6. 범위 밖 — 별건 권고 Rev.1 §6 의 세 항목(`pane_pid` 가 caffeinate 의 pid 를 반환 / `reconcile.sh:374` 디버그 출력 / `IMPROVEMENTS.md` B-4 서술 정정)은 그대로 유효하다. Rev.2 에서 하나 추가한다. ### 6.1 `find_workspace_uuid` 의 무행(row-less) 디스크 스캔 폴백 §1.3 에서 드러난 구조다. 행 기반 검사가 거부해도 그 뒤의 폴백이 `verify_session_uuid(ws, agent, cand)` 를 **행 없이** 호출하므로 `epoch = 0` 이 되어 하한이 적용되지 않고, 같은 파일이 그대로 통과한다. - 지금은 이것이 resume 을 살리고 있다(그래서 C-1 이 사용자에게 닿지 않는다). - 동시에 B-4 가 세우려는 하한을 이 경로에서 무력화한다. F6 을 넣으면 resume 은 더 이상 이 폴백에 의존하지 않으므로, 폴백을 조일 수 있는 상태가 된다. **다만 조이는 작업 자체는 이 브리프의 범위가 아니고, 조이면 무엇이 깨지는지 별도 측정이 필요하다.** F6 은 그 선행 조건을 만들어 둘 뿐이다. --- ## 7. 부록 — 코드 전문 ### 7.1 F1 + F7 + F8 — `lib.sh` 시프트 `ls` 핸들러 최종형 `lib.sh:545` 의 `ls)` 분기 전체를 다음으로 교체한다. (Rev.1 §7.1 을 대체한다.) ```bash ls) format="" while [ $# -gt 0 ]; do case "$1" in -F) if [ $# -lt 2 ]; then echo "Error: -F requires a value" >&2 exit 1 fi format="$2" shift 2 ;; *) shift ;; esac done # herdr has no session-creation timestamp: `agent list`, `agent get`, # `pane get` and `api snapshot` all lack one. Derive it from the OS as the # start time of the pane's ROOT process (`shell_pid`). # # It must NOT come from `foreground_processes[0]`: under MAM that slot # holds the `caffeinate -i -t 300` keep-awake wrapper, which respawns every # five minutes. Its start time creeps forward, so a session idle for longer # than one caffeinate cycle would look "created after" its own transcript # and be discarded by the stale-transcript guard in verify_session_uuid. # # This runs as ONE python process that re-implements `_real_herdr` and # batches a single `ps` over every pid, rather than forking python and ps # once per agent inside a shell loop: reconcile calls this on a 15s # heartbeat, so per-agent forking shows up as a standing cost. # # TZ=UTC pins both `ps` (which prints lstart in the caller's zone) and # `mktime` (which reads it back in the caller's zone) to the same zone. # They already agree at any single TZ, so this is not about skew between # them -- it removes the once-a-year DST ambiguity in local-time mktime. # LC_ALL=C pins lstart's field names, which are locale-dependent. _sess="${HERDR_SESSION_NAME:-}" if [ "$_sess" = "default" ]; then _sess="" fi _real_herdr agent list 2>/dev/null | \ MAM_LS_FORMAT="$format" MAM_REAL_HERDR="$REAL_HERDR" MAM_LS_SESSION="$_sess" \ TZ=UTC LC_ALL=C python3 -c ' import json, os, subprocess, sys, time fmt = os.environ.get("MAM_LS_FORMAT", "") real = os.environ.get("MAM_REAL_HERDR") or "herdr" sess = os.environ.get("MAM_LS_SESSION") or "" base = [real] + (["--session", sess] if sess else []) raw = sys.stdin.read() try: agents = json.loads(raw).get("result", {}).get("agents", []) or [] except Exception: # Unparseable means "we do not know", not "there are no sessions". # Exiting nonzero lets reconcile fall into its herdr_confirmed=False path # instead of reading empty stdout as a confirmed zero and terminating # every live row it has on file. sys.exit(1) rows = [] for a in agents: name = a.get("name") or a.get("agent") or "unknown" pane = a.get("pane_id") or "" pid = None if pane: try: out = subprocess.run(base + ["pane", "process-info", "--pane", pane], capture_output=True, text=True, timeout=10).stdout pi = json.loads(out).get("result", {}).get("process_info", {}) p = pi.get("shell_pid") or pi.get("foreground_process_group_id") if isinstance(p, int) and p > 0: pid = p except Exception: pass rows.append((name, pid)) starts = {} pids = sorted({p for _, p in rows if p}) if pids: try: out = subprocess.run(["ps", "-o", "pid=,lstart=", "-p", ",".join(str(p) for p in pids)], capture_output=True, text=True, timeout=10).stdout for line in out.splitlines(): line = line.strip() if not line: continue head, _, rest = line.partition(" ") try: starts[int(head)] = int(time.mktime(time.strptime(rest.strip(), "%a %b %d %H:%M:%S %Y"))) except Exception: pass except Exception: pass # Degrade to now, never to 0 or a sentinel. The consumer guard is # if epoch and mtime(transcript) < epoch: reject # so an over-estimate only makes it stricter, while an under-estimate # (0 is falsy; 999999 is 1970-01-12 and below every real mtime) switches # the guard off outright -- which is the B-4 defect. now = int(time.time()) for name, pid in rows: if fmt == "#{session_name}": print(name) else: print(name + "|" + str(starts.get(pid) or now)) ' ;; ``` ### 7.2 F2 — `reconcile.sh` 위생 처리 Rev.1 §7.2 그대로다(바닥값 상수 `MAM_EPOCH_FLOOR`, 국소 `except ValueError`, 등록 폴백 `int(time.time())`). 변경 없음. ### 7.3 F5 — `reconcile.sh` `lib_sh` 바인딩 §2.1 참조. ### 7.4 F6 — `verify_session_uuid` §2.2 참조. ### 7.5 F3 / F4 — 테스트 인프라 Rev.1 §7.3 / §7.4 그대로다. 변경 없음. ### 7.6 테스트 파일 ``` scratchpad/rev2/tests/test_b4_session_created.py (21건, 최종형) ``` 재현용 독립 프로브: - `scratchpad/probe_b4.sh ` — B-1..B-6 을 6줄로 출력 - `scratchpad/probe_harm.sh ` — B-4 피해 재현 - `scratchpad/probe_resume_epoch.sh ` — §1.1 (resume 이 epoch 를 덮는가) - `scratchpad/probe_c1c.sh ` — §1.3 (resume 이 대화를 되찾는가) - `scratchpad/probe_f6.py` — §1.3 (discover / revalidate 판정표) --- ## 8. 검증 로그 ``` tests/test_b4_session_created.py b4base (clean HEAD) : 2 / 21 b4fix (Rev.1) : 18 / 21 B-11, B-12, B-13 실패 rev2 (Rev.2) : 21 / 21 변이 b4mut (except ValueError 제거) B-8 ×2 실패 b4mut2 (fg[0] 사용) B-3, B-4 실패 성능 (라이브 에이전트 3개, 20회 평균) HEAD 42.6 ms/call Rev.1 156.0 ms/call Rev.2 72.7 ms/call (-53%) bash -n lib.sh / reconcile.sh OK shellcheck 로컬 미설치 — CI 확인 필요 ``` ### 8.1 회귀 결과 ``` b4base (clean HEAD) : 128 passed in 504.37s b4fix (Rev.1) : 143 passed in 467.19s (128 + 신규 15) rev2 (Rev.2) : 149 passed in 623.78s (128 + 신규 21) ``` 기존 128건 전부 유지, 실패·에러 0건. F5(`lib_sh` 바인딩)와 F6(모드 한정 하한)이 기존 reconcile·resume 테스트를 건드리지 않음을 확인했다. 라이브 시스템에서 Rev.1 과 Rev.2 의 `ls` 출력이 두 포맷 모두 바이트 단위로 동일함도 확인했다 — F7 은 성능 변경이지 동작 변경이 아니다. ``` [Rev.1] ...-creator-cline|1786194117 [Rev.2] ...-creator-cline|1786194117 ...-creator-agy|1786194156 ...-creator-agy|1786194156 ...-creator-claude|1786194098 ...-creator-claude|1786194098 ``` --- ## 9. Challenger 회신 (agy) 세 지적 모두 코드를 정확히 읽은 데서 나왔고, 두 건은 계획서를 실제로 개선했다. 다만 **셋 다 결론까지 가는 중간 단계가 실측과 어긋난다.** - **C-1** — `resume_session.sh` 는 `herdr_session_epoch` 를 덮지 않는다(기존 행일 때). 검증 실패로 id 를 지우는 코드도 없다. 그리고 사용자 경로에서 재현되지 않는다 — 세 트리 모두 대화를 되찾는다. 다만 **행 기반 revalidate 가 실제로 거부한다**는 관찰은 맞았고, 그것이 폴백에 가려져 있다는 사실을 드러낸 것이 이번 개정의 수확이다(F6, §6.1). - **C-2** — 맞다. 실측 156 ms → 72.7 ms. 제안한 방향 그대로 채택했다. - **C-3** — `ps` 와 `mktime` 은 같은 호출 안에서 같은 `TZ` 를 보므로 스큐가 날 수 없다(4종 TZ 실측, epoch 전부 동일). 조치(`TZ=UTC`)는 **다른 이유로** 채택했다 — Rev.1 이 남겨 둔 DST 모호성이 사라진다. 그리고 이 검토가 아니었다면 찾지 못했을 것: **`reconcile.sh` 의 쓰기 경로는 drift-C 가 후보를 찾는 순간 `NameError` 로 죽고 있었다.** C-1 이 지키려던 그 경로다. F1 은 후보를 비워 그 크래시를 가리기까지 했다. 이의제기가 없었으면 가려진 채로 넘어갔을 결함이다. [AGREEMENT: REACHED] --- ## 10. 산출물과 경계 - **작성한 것**: 이 계획서, `scratchpad/` 의 검증 트리(`b4base`/`b4fix`/`rev2`/`b4mut`/`b4mut2`)·프로브 5종·테스트 파일. - **저장소에 가한 변경**: 없음. 모든 프로토타이핑은 `git archive HEAD | tar -x` 로 뜬 별도 트리에서 수행했다. - **Creator 가 할 일**: §5 순서대로 F5 → F1 → F7/F8 → F2 → F6 → F3 → F4 적용, `tests/test_b4_session_created.py` 추가, CI shellcheck 잡 확인. - **권고(별건)**: Rev.1 §6 의 3건 + §6.1 무행 폴백.