Files
multi-agent-mux/.agents/reports/canary-projects-multi-agent-mux-creator-claude/plan-94687096.md
T

28 KiB
Raw Blame History

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 / mktimeTZ 의존으로 수 시간 스큐 메커니즘 기각 / 조치는 채택 동일 호출 내에서 둘은 모든 TZ 에서 정확히 일치한다(4종 실측, epoch 동일). 다만 TZ=UTC 고정은 별개 이유로 이득이 있어 채택
신규 차단(Blocker) reconcile.sh 쓰기 경로가 drift-C 진입 시 NameError 로 죽는다. HEAD 에서 rc=1. C-1 이 지키려던 그 경로가 이미 죽어 있었다

추가 변경은 4건(F5F8)이고, Rev.1 의 F1F4 는 유지된다.

대상 내용
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.shherdr_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_shexcept 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 F1F4 + 신규 F5F8)

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 에서 보았듯 사용자에게 보이는 동작을 바꾸지 않는다 — 지금은 하한 우회 폴백이 뒤를 받치고 있기 때문이다. 채택하는 이유는 두 가지다.

  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 + 3N + 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-388rc=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 는 psmktime 이 서로 다른 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=Falserevalidate=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. F1F7/F8 — F7 은 F1 의 ls 핸들러를 다시 쓰는 것이므로 한 커밋으로 묶어도 좋다. 나누려면 반드시 F1 먼저.
  3. F2 — F1 과 독립.
  4. F6 — 단독. discover/revalidate 양쪽을 B-12 로 확인.
  5. F3F4 — 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:545ls) 분기 전체를 다음으로 교체한다. (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-1resume_session.shherdr_session_epoch 를 덮지 않는다(기존 행일 때). 검증 실패로 id 를 지우는 코드도 없다. 그리고 사용자 경로에서 재현되지 않는다 — 세 트리 모두 대화를 되찾는다. 다만 행 기반 revalidate 가 실제로 거부한다는 관찰은 맞았고, 그것이 폴백에 가려져 있다는 사실을 드러낸 것이 이번 개정의 수확이다(F6, §6.1).
  • C-2 — 맞다. 실측 156 ms → 72.7 ms. 제안한 방향 그대로 채택했다.
  • C-3psmktime 은 같은 호출 안에서 같은 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 무행 폴백.