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

528 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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건(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.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 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:` **바깥**에서 바인딩한다. 기존 분기 내부의 대입은 제거한다.
```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 <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 무행 폴백.