28 KiB
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 이다.
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: 바깥에서 바인딩한다. 기존 분기 내부의 대입은 제거한다.
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 채택)
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 에서 보았듯 사용자에게 보이는 동작을 바꾸지 않는다 — 지금은 하한 우회 폴백이 뒤를 받치고 있기 때문이다. 채택하는 이유는 두 가지다.
revalidate라는 이름이 약속하는 계약을 코드가 지키게 된다. 이미 이 행이 기록한 id 를 재확인하는 자리이고,sessionId·cwd대조는 그대로 남는다.- 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. 적용 순서와 리스크
순서
- F5 — 단독 선행. 차단 항목이고, 다른 변경과 무관하게 그 자체로 결함 수정이다. 이후 단계의 관측이 가능해진다.
- F1 → F7/F8 — F7 은 F1 의
ls핸들러를 다시 쓰는 것이므로 한 커밋으로 묶어도 좋다. 나누려면 반드시 F1 먼저. - F2 — F1 과 독립.
- F6 — 단독.
discover/revalidate양쪽을 B-12 로 확인. - 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 을 대체한다.)
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 <tree>— B-1..B-6 을 6줄로 출력scratchpad/probe_harm.sh <tree>— B-4 피해 재현scratchpad/probe_resume_epoch.sh <tree>— §1.1 (resume 이 epoch 를 덮는가)scratchpad/probe_c1c.sh <tree>— §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 무행 폴백.