모달의 시그니처와 수락 키를 에이전트 어댑터(startup_dialogs → MAM_STARTUP_DIALOGS)로 옮기고 handle_startup_dialogs 를 에이전트 무관으로 만들었다. agy·grok·codex 의 신뢰 창과 codex 0.155.1 업데이트 안내가 모두 처리된다. --no-auto-trust / MAM_AUTO_TRUST=0 으로 신뢰 창만 사람에게 남길 수 있다. resume 도 create 와 같은 계약을 쓴다. 수락 판정은 다이얼로그 시그니처가 아니라 준비 토큰으로 한다. --source visible 이 수락된 창을 한동안 계속 보여주기 때문이다.
herdr pane 은 HERDR_WORKSPACE_ID·HERDR_PANE_ID·HERDR_TAB_ID 를 내보내는데, 이 값은 발급한 서버에서만 의미가 있다. 다른 서버를 겨냥한 호출에서는 세 값을 제거한다. 호출자 서버는 HERDR_SOCKET_PATH 의 모양에서 구한다(HERDR_SESSION 이 있으면 우선). herdr 0.8.2 는 기본 서버의 pane 에 HERDR_SESSION 을 내보내지 않으며, 그 pane 이 바로 이 결함이 나오는 곳이다.
전달 확인은 화면이 아니라 프로토콜로 한다. delegate_publish_cmd 가 create 의 온보딩과 delegate-job 래퍼에 같은 발행 명령 모양을 주고, delegate_started_recorded 하나를 두 대기 지점이 공유한다. 이미 종료된 잡은 덮어쓰지 않는다. 원격 경로에서는 구독자가 HMAC 과 디스패치 토큰을 검증한 뒤 dispatch_fingerprint(검증한 토큰의 SHA256)와 시도 번호를 감사 기록에 남기고, 토큰을 요구하는 판정은 검증 표시와 지문 일치를 함께 요구한다. 신원이 없는 과거 기록은 fail closed 다.
롤백이 kill 결과를 확인하고 has-session 으로 재확인한다. 죽이지 못했으면 행에 그 사실을 적고 exit 43 으로 끝낸다. 온보딩이 확인되지 않아도 세션은 보존한다(MAM_ONBOARD_ACK_S). 살아 있는 에이전트를 느린 온보딩 때문에 죽이는 것은 지키려는 것보다 잃는 것이 크고, 준비되지 않은 TUI 를 진단용으로 보존하는 바로 앞 단계의 규칙과도 어긋난다.
검증
항목
결과
전체 스위트 (f4ba205)
1080 통과 / 12 실패 / 20 건너뜀, 42분 59초
리뷰어 5명 (087eaca..f4ba205)
전원 PASS
실제 herdr 수용 시험
agy·grok·codex·opencode 4건 전부 통과
실패 12건은 모두 이 호스트의 기존 기준선 실패(tests/test_issue10_13_purge.py)이며 base 에서도 동일하게 실패한다. 새 실패는 없다.
실제 herdr 시험은 오케스트레이터 pane 의 상속 환경을 일부러 그대로 둔 채, 고정된 detached 사본의 코드로 격리 서버에 네 에이전트를 순차 생성해 수행했다. 네 건 모두 사람 개입 없이 TUI 가 준비되고, 온보딩 프롬프트가 전달되고, 잡이 started(디스패치 토큰 포함) 에서 completed 로 끝났다. grok 의 텔레메트리 opt-in 배너는 입력을 막지 않음을 실측해 다이얼로그 항목을 추가하지 않았다.
리뷰에서 나온 차단 지적과 대응
시험과 리뷰 다섯 라운드에서 차단 지적 세 건이 나왔고 모두 해소했다. 세 건 모두 같은 뿌리를 가진다. 테스트 픽스처가 실제 생산자와 달라서 결함을 가렸다.
교차 서버 판정이 HERDR_SESSION 에만 의존해 이 호스트에서 무력했다. 실제 herdr 시험에서 드러났다.
온보딩 ack 가 data.dispatch_token 을 요구했으나 에이전트가 쓰는 publish_event.py 는 그 필드를 쓰지 않았다. 픽스처가 그 줄을 손으로 만들면서 토큰을 넣어 통과시키고 있었다. 온보딩을 완료한 에이전트가 180초 뒤 미확인으로 판정됐다.
래퍼의 대기가 공유 판정 앞에서 레코드 상태만 보고 먼저 성공했고, 그 수정의 새 테스트가 레코드를 pending 으로 되돌려 우회를 가렸다. 이어서 원격 감사 기록이 신원 없는 불리언만 남겨 이전 시도의 증거가 다음 시도를 만족시켰다.
픽스처는 이제 실제 registry.py 로 잡을 등록하고 실제 publish_event.py 로 이벤트를 발행한다. 마지막 지적은 리뷰어가 제출한 재현 자산을 수정 없이 돌려 확인했다. 수정 전 2건 실패, 수정 후 5건 통과다.
별건
리뷰 루프가 diff 를 단일 argv 로 넘겨, 브랜치 diff 가 커널의 단일 인자 상한 131,072 바이트를 넘으면 OSError: [Errno 7] 로 죽고 종료 코드 0 으로 끝난다. 수집기 상한(MAM_DIFF_MAX_BYTES, 기본 200000)이 커널 상한보다 높아 걸러지지 않는다. 이 PR 의 4·5차 리뷰는 델타 범위로 우회해 수행했다. 별도 이슈로 등록한다.
Closes #23
Closes #24
오케스트레이터 pane 의 herdr 환경이 격리 서버 조회를 오염시켜 온보딩 프롬프트가 전달되지 않던 문제(#23)와, 시작 차단 모달이 claude 에게만 처리되어 다른 에이전트가 신뢰 창에 갇히던 문제(#24)를 한 묶음으로 해결한다.
## 변경 요지
**#24 — 시작 다이얼로그가 에이전트 무관으로 처리된다**
모달의 시그니처와 수락 키를 에이전트 어댑터(`startup_dialogs` → `MAM_STARTUP_DIALOGS`)로 옮기고 `handle_startup_dialogs` 를 에이전트 무관으로 만들었다. agy·grok·codex 의 신뢰 창과 codex 0.155.1 업데이트 안내가 모두 처리된다. `--no-auto-trust` / `MAM_AUTO_TRUST=0` 으로 신뢰 창만 사람에게 남길 수 있다. resume 도 create 와 같은 계약을 쓴다. 수락 판정은 다이얼로그 시그니처가 아니라 준비 토큰으로 한다. `--source visible` 이 수락된 창을 한동안 계속 보여주기 때문이다.
**#23 — 교차 서버 호출이 호출자 pane 의 스코프를 떨어뜨린다**
herdr pane 은 `HERDR_WORKSPACE_ID`·`HERDR_PANE_ID`·`HERDR_TAB_ID` 를 내보내는데, 이 값은 발급한 서버에서만 의미가 있다. 다른 서버를 겨냥한 호출에서는 세 값을 제거한다. 호출자 서버는 `HERDR_SOCKET_PATH` 의 모양에서 구한다(`HERDR_SESSION` 이 있으면 우선). herdr 0.8.2 는 기본 서버의 pane 에 `HERDR_SESSION` 을 내보내지 않으며, 그 pane 이 바로 이 결함이 나오는 곳이다.
**#23 — 온보딩 전달 판정이 실제 에이전트가 발행하는 것으로 충족된다**
전달 확인은 화면이 아니라 프로토콜로 한다. `delegate_publish_cmd` 가 create 의 온보딩과 delegate-job 래퍼에 같은 발행 명령 모양을 주고, `delegate_started_recorded` 하나를 두 대기 지점이 공유한다. 이미 종료된 잡은 덮어쓰지 않는다. 원격 경로에서는 구독자가 HMAC 과 디스패치 토큰을 검증한 뒤 `dispatch_fingerprint`(검증한 토큰의 SHA256)와 시도 번호를 감사 기록에 남기고, 토큰을 요구하는 판정은 검증 표시와 지문 일치를 함께 요구한다. 신원이 없는 과거 기록은 fail closed 다.
**#23 — 실패 처리**
롤백이 kill 결과를 확인하고 `has-session` 으로 재확인한다. 죽이지 못했으면 행에 그 사실을 적고 exit 43 으로 끝낸다. 온보딩이 확인되지 않아도 세션은 보존한다(`MAM_ONBOARD_ACK_S`). 살아 있는 에이전트를 느린 온보딩 때문에 죽이는 것은 지키려는 것보다 잃는 것이 크고, 준비되지 않은 TUI 를 진단용으로 보존하는 바로 앞 단계의 규칙과도 어긋난다.
## 검증
| 항목 | 결과 |
|---|---|
| 전체 스위트 (`f4ba205`) | 1080 통과 / 12 실패 / 20 건너뜀, 42분 59초 |
| 리뷰어 5명 (`087eaca..f4ba205`) | 전원 PASS |
| 실제 herdr 수용 시험 | agy·grok·codex·opencode 4건 전부 통과 |
실패 12건은 모두 이 호스트의 기존 기준선 실패(`tests/test_issue10_13_purge.py`)이며 base 에서도 동일하게 실패한다. 새 실패는 없다.
실제 herdr 시험은 오케스트레이터 pane 의 상속 환경을 일부러 그대로 둔 채, 고정된 detached 사본의 코드로 격리 서버에 네 에이전트를 순차 생성해 수행했다. 네 건 모두 사람 개입 없이 TUI 가 준비되고, 온보딩 프롬프트가 전달되고, 잡이 `started`(디스패치 토큰 포함) 에서 `completed` 로 끝났다. grok 의 텔레메트리 opt-in 배너는 입력을 막지 않음을 실측해 다이얼로그 항목을 추가하지 않았다.
## 리뷰에서 나온 차단 지적과 대응
시험과 리뷰 다섯 라운드에서 차단 지적 세 건이 나왔고 모두 해소했다. 세 건 모두 같은 뿌리를 가진다. **테스트 픽스처가 실제 생산자와 달라서 결함을 가렸다.**
1. 교차 서버 판정이 `HERDR_SESSION` 에만 의존해 이 호스트에서 무력했다. 실제 herdr 시험에서 드러났다.
2. 온보딩 ack 가 `data.dispatch_token` 을 요구했으나 에이전트가 쓰는 `publish_event.py` 는 그 필드를 쓰지 않았다. 픽스처가 그 줄을 손으로 만들면서 토큰을 넣어 통과시키고 있었다. 온보딩을 완료한 에이전트가 180초 뒤 미확인으로 판정됐다.
3. 래퍼의 대기가 공유 판정 앞에서 레코드 상태만 보고 먼저 성공했고, 그 수정의 새 테스트가 레코드를 `pending` 으로 되돌려 우회를 가렸다. 이어서 원격 감사 기록이 신원 없는 불리언만 남겨 이전 시도의 증거가 다음 시도를 만족시켰다.
픽스처는 이제 실제 `registry.py` 로 잡을 등록하고 실제 `publish_event.py` 로 이벤트를 발행한다. 마지막 지적은 리뷰어가 제출한 재현 자산을 수정 없이 돌려 확인했다. 수정 전 2건 실패, 수정 후 5건 통과다.
## 별건
리뷰 루프가 diff 를 단일 argv 로 넘겨, 브랜치 diff 가 커널의 단일 인자 상한 131,072 바이트를 넘으면 `OSError: [Errno 7]` 로 죽고 종료 코드 0 으로 끝난다. 수집기 상한(`MAM_DIFF_MAX_BYTES`, 기본 200000)이 커널 상한보다 높아 걸러지지 않는다. 이 PR 의 4·5차 리뷰는 델타 범위로 우회해 수행했다. 별도 이슈로 등록한다.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
A MAM script often runs inside one herdr server's pane (the orchestrator's)
while targeting another (the isolated MAM server). The pane exports
HERDR_WORKSPACE_ID, HERDR_PANE_ID and HERDR_TAB_ID, and those ids only mean
something on the server that issued them. They reached the scope filters as a
foreign workspace, whose fail-closed miss (ISSUE-3 / F-2b) reports a live
session as absent: has-session, kill-session, agent prompt and the delegate-job
preflight all answered "no such session". Reproduced on Linux and macOS in the
#23 comments, worked around until now with `env -u` at every call site.
mam_herdr drops the three ids for a call aimed at a named server other than the
caller's own. Scope then falls back to .mam/herdr_workspace_id and finally to
server-global, which is what _herdr_ws_scope already does. `default` is not a
crossing: it is the sentinel for "pass no session flag", so herdr answers on
the ambient server, which for a caller in a pane is that caller's own. A caller
outside any pane keeps an exported scope, so H-18/H-21/H-23 keep their meaning.
The fix lives in mam_herdr, not in the shim heredoc: that heredoc is
materialised into $WORKSPACE_ROOT/.mam/shim/herdr, one file shared by every
checkout bound to a control plane and rewritten on every herdr call, so editing
it would put unreviewed code in the control root and let two checkouts
overwrite each other. S11 fixes that invariant.
lib_py/loop/watcher.py builds its own herdr argv and is the one path that does
not go through mam_herdr, so it applies the same rule via scoped_env.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
handle_startup_dialogs was a claude-only string ladder called only when
AGENT=claude, and wait_for_tui_ready does not treat a dialog screen as ready.
So agy, grok and codex sat on their directory-trust prompt for the full
readiness timeout, the session was recorded as tui-ready-timeout, and
onboarding injection was disabled (create_session.sh:313-314) - a human had to
accept each dialog by hand.
The signatures and accept keys move to the adapters as `startup_dialogs`
(pattern, keys, kind), published through the existing `facts` eval contract as
MAM_STARTUP_DIALOGS. claude's ladder moves over verbatim; agy takes Enter, grok
y, codex Enter, and codex's update notice takes 2 (Skip), which blocks a start
just as hard while changing nothing. kind is `trust` or `notice`:
--no-auto-trust leaves trust dialogs to a human and still answers notices,
since trusting a directory makes codex load its project-local config, hooks and
exec policy.
Re-sending is gated by time (MAM_DIALOG_RESEND_S, default 5s), not by frames.
#24's comment reports that `--source visible` keeps showing an accepted dialog
for a while, and _pane_capture hardcodes that source, so "still on screen" is
not evidence the keys were missed; acceptance is judged by the ready tokens
alone.
Two existing tests asserted the old shape and are updated, not weakened:
b19's "Escape branch stays" now looks for it in claude's adapter, and c1b's
"Enter once per iteration" becomes "never twice for one frame", which the
cooldown satisfies strictly more tightly.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Plan commits 3 and 4 landed together: both rewrite the same region of
create_session.sh and share one test file, and splitting them afterwards would
have meant hunk-level surgery on already-verified code.
Delivery verdict (item 3). create_session --onboard called a healthy agent a
failure three seconds after registering the job: inject_instructions ->
send_keys_safe returns rc 4 when `agent prompt --wait --until working` fails
and the pane is still not working two seconds later, which is exactly where a
TUI that is still starting up lands. It never got the ISSUE-18 confirmation the
delegate-job wrapper has. rc 4 is a warning now and the agent's own `started`
decides, within the job's own ack timeout; rc 1/2/3 stay hard failures, since
they say the pane never quiesced, a dialog was blocking, or the text never
appeared, and no later `started` repairs those.
The self-published `started` had to go first. create_session published it on
the agent's behalf right after injection, so the registry showed a started job
whether or not the agent ever saw the prompt - with that line in place, no
started-based verdict can mean anything.
delegate_wait_started is lib.sh's counterpart to the wrapper's _wait_started
(plan item D10-b). The wrapper's own `submit` was the first choice, but it
blocks until the job reaches a terminal state, and create_session must return
as soon as delivery is confirmed; the on-disk evidence it reads - registry
status past `pending`, or a started line carrying this dispatch token - is
written by publish_event before its wire publish, so no broker is involved.
Rollback integrity (item 4). The trap ran `_herdr kill-session -t
"$SESSION_NAME" 2>/dev/null || true`, so a kill that closed nothing still
printed "rolling back" while the session kept running, and the row stayed
`running` pointing at the job the same rollback had just failed (#23 symptom
5). With item 1 in place this check is finally trustworthy: the kill used to
fail because the caller's workspace id hid the pane.
The trap now reads the kill's rc, confirms with has-session, closes the
onboarding job as `cancelled` (a terminal transition the registry knows, and
the truthful one - the job was never dispatched), and marks the row. A session
still alive exits 43 with the manual cleanup command, distinct from 42 (TUI
never ready).
The row keeps a schema-valid status rather than the plan's `error`, which
atomic_yaml.py rejects: `terminated` once the session is really gone, and
`running` when the kill failed, because it is still running. What makes that
case legible is the note, the cleared delegate_job_id and exit 43 - not a
status claiming the opposite of the truth. The row is never deleted; a deleted
row takes the evidence with it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two defects in the started-based verdict, both found by the full suite.
The first was destructive. No `started` within T exited 1, which fired the
rollback trap and killed the session. That is worse than the bug being fixed:
create_session already has a rule for "the agent is not responding but the
process is alive" one step earlier - a TUI that never signalled ready is
preserved for diagnosis - and an onboarding that is merely slow is the same
situation. The job is marked `error` and warned about loudly; the session, its
row and the pane stay. MULTI_AGENT_RULES §6 has the GM gate on this job
anyway, so an unacknowledged onboarding still blocks real work.
It also broke 21 tests across 8 modules (create/sanity/tier2/tier3/tier4/
issue15/issue19/issue21) and added roughly an hour to the suite: no sandbox
has an agent to publish `started`, so every create_session test sat out the
full 180 s and then failed on the rollback. MAM_ONBOARD_ACK_S shortens that
wait, and the sandbox fixture sets 3 s. It is deliberately not the wrapper's
MAM_INJECT_ACK_GRACE_S - that is a different wait whose own tests assert on
the value they pass, and reusing it made issue18's acceptance2 read "within
3s" instead of "within 4s".
The second: the delivery failure published `error` and then the rollback
published `cancelled` on the same job, which the registry refuses
("conflicting cancelled after terminal error"). Terminal events are published
exactly once, so the trap now skips a job this run already closed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
resume_session.sh called handle_startup_dialogs only when AGENT=claude and
passed no agent, so a resumed agy, grok or codex sat on its directory-trust
prompt exactly as the create path did before this branch. The gate is gone and
the agent is passed, which is the call create already makes.
--no-auto-trust / MAM_AUTO_TRUST apply to resume too. A resumed CLI shows the
same trust dialog as a fresh one, in the same directory, with the same
consequence for codex (trusting loads the project's local config, hooks and
exec policy), and handle_startup_dialogs already reads MAM_AUTO_TRUST - so the
only question was whether resume exposes the switch or silently inherits the
caller's environment. Exposing it keeps the two skills' contracts identical.
The tests run the shipped create/stop/resume scripts against the mock herdr.
The mock's pane always shows a ready banner, so they fix the call resume makes
(session, timeout, agent, MAM_AUTO_TRUST) by overriding the handler in the
sandbox copy of lib.sh, plus one unmodified run showing the real handler is
reached for a non-claude agent; which keys a dialog gets is fixed on the create
side (S4-S6).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Review round 1 (planner-reviewer-claude-01, reviewer-agy-01,
planner-reviewer-codex-01): one blocking defect and two P2s, all confirmed.
B-1. handle_startup_dialogs took its ready tokens only from the environment,
which wait_for_tui_ready fills when it evaluates the adapter facts. Called on
its own - as resume_session.sh calls it, with no readiness wait before it -
it had no tokens, its early-return branch could never fire, and every resume
of every agent idled through the full 20 s timeout (base: 0.03 s). rc was 0
throughout, so nothing failed; the resume tests just took ~30 s each.
_mam_load_dialog_facts now also takes MAM_STRONG_READY_TOKENS / MAM_READY_TOKENS
from the facts, but only when the caller has not set them, so a caller-set
value (wait_for_tui_ready's own, or a test steering readiness) is never
clobbered - the concern that made it take a single fact in the first place.
S5d pins one-iteration return on a ready first frame for five adapters with
no environment; S5e pins that caller tokens survive; R4 gets a wall-clock cap
so a timeout-idle can no longer pass as success. The old
_MAM_READY_TOKENS_CLAUDE fallback was left defined but unread - removed.
R1 / N-2. delegate_wait_started treated any status past `pending` as the
agent's acknowledgement. codex reproduced three false positives: `error` and
`cancelled` records with no started line, and a `running` record from another
dispatch. The status branch is gone; the evidence is a started line carrying
this dispatch's token, which the local publisher writes before it syncs the
status, so nothing that really started is missed (S7c re-pinned on the line
with running/completed/error statuses; S7f/S7g pin the three negatives).
N-4, taken with it: the events.log line is parsed as JSON rather than pattern-
matched. publish_event nests dispatch_token under `data`, and the old regex
only matched because of key order (S7h, real line shape and a reordered one).
R2 / N-1. Two tests hardcoded <repo>/.venv/bin/python and failed with
FileNotFoundError in every review worktree. sys.executable.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The live trial of this branch found the cross-server verdict inert on this
host: _herdr_call_is_cross_server read the caller's server from HERDR_SESSION,
and herdr 0.8.2 does not export that variable to a pane of the default server
- the orchestrator's usual home, and exactly the pane the #23 leak comes from.
Every call was judged "not crossing", the three ids were never dropped, and
all four trial onboardings ended "no started within 180s". My own pane sits in
a named server and does carry HERDR_SESSION, which is how the assumption got
past me.
HERDR_SOCKET_PATH is exported to every pane and its layout names the server:
.../.config/herdr/herdr.sock is the default server and
.../.config/herdr/sessions/<name>/herdr.sock a named one - the convention
`herdr session list --json` reports and the shim already uses to find a named
server's socket. _herdr_caller_server prefers HERDR_SESSION when a herdr does
export it, otherwise reads the socket's shape (suffix-matched, not $HOME-
anchored), and yields "" for anything unrecognised so an exported scope is
respected rather than guessed away. The rest of the rule is unchanged from
3bcaa39: a `default` or empty target is the ambient server, no caller server
means no pane, caller == target is the same server. watcher.caller_server
applies the same rule for the loop's own herdr calls.
Reproduced against the live server in the trial's exact env: 10a678c answers
"NOT crossing / has-session NOT FOUND"; this commit answers "caller server
default / crossing / FOUND". S1c pins that env end to end with the recorder
asserting the ids never reach herdr; S1d-S1h pin the verdict, the named-socket
same-server and cross-server cases, HERDR_SESSION precedence, and the
unrecognised layout. The 3bcaa39 tests are unchanged and still pass.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Trial 2 of this branch: cross-server delivery was restored (f7cee00), the
agent completed its onboarding and published started and completed - and
create_session still waited 180 s, called the onboarding unconfirmed, and
tried to write `error` over the completed job (refused by the registry).
delegate_wait_started required the started line to carry data.dispatch_token
(review R1: a stale attempt's started must not vouch for this one). But
publish_event only writes that field when its command line carries
--expect-dispatch-token, and create_session's hand-rolled onboarding command
never did - the wrapper's _pub_cmd does, which is why every wrapper-dispatched
agent's started has the token and no create_session onboarding ever could.
Five reviewers missed it because the fixture wrote the token into the started
line by hand: a fake producer that differs from the real one.
Fix, along the wrapper's existing mechanism rather than a new one:
* delegate_publish_cmd (lib.sh) builds the publish command for an agent, with
--expect-dispatch-token and --attempt-id, and create_session uses it. The
agent's own started now names the dispatch, and publish_event's CAS check
refuses a stale dispatch's event at the source. S7l pins the builder
argument-for-argument against the wrapper's _pub_cmd.
* delegate_started_recorded (lib.sh) is the one definition of "this
dispatch's started is on disk" - JSON-parsed, token-matched when a token is
given - used by delegate_wait_started and by the wrapper's _wait_started in
place of its regex. The wrapper keeps its status and subscriber routes
(remote agents); the shared part is the evidence contract.
* An already-terminal job is left alone when the wait expires (req. 4): no
error over a completed/error/cancelled record.
The fixture now registers the record through registry.py and publishes
started through the real publish_event.py; the only synthetic line left (S7h)
exists to pin key-order independence and says so. S7i pins the trial-2 defect
as the negative it is (old command shape -> started without token -> not
acknowledged); S7j runs the builder's command through the real producer and
is acknowledged on the first probe; S7n shows the wrapper accepting the same
real line and rejecting a stale token; S7m pins no-write-over-terminal. S7f
moves from "publish error" to "leave the terminal job alone", per req. 4.
Option B (verify data.hmac_sig with the job's auth_token, plus record-token
still equal) was weighed and not taken: verify_hmac returns True with no
auth_token - PoC mode, which the trial and every sandbox run in - so it proves
nothing there; in production hmac_sig authenticates the signer but does not
identify the dispatch, so R1 would rest on a current-state check that a stale
line plus an unrotated token passes; and it would diverge from the wrapper
unless the wrapper changed too. Token exposure is not new: every wrapper-
dispatched instruction already carries the dispatch token in the pane, and it
is a per-dispatch CAS nonce, not the HMAC secret.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The full suite on 1667fda showed exactly one failure beyond this host's
baseline: test_g9_phase2a_spawns_no_daemon_and_calls_no_frozen_helper. Its
`\blib\.sh\b` pattern matched a docstring in watcher.py that points at the
shell twin of caller_server ("Same convention as lib.sh's
_herdr_caller_server") - a mention written so the two implementations stay in
sync, not a call.
The guard's own docstring states its claim: no frozen lib.sh helper is
*invoked* from lib_py/loop. Its one prior change (803ba43) added the word
boundary after `hashlib.sha256` tripped it - the same class of false positive,
handled by narrowing the matcher rather than by contorting innocent code.
This continues that: the three patterns and the word boundary are unchanged,
but they now run over the source with comments and docstrings blanked. Every
way Python could actually reach a lib.sh helper - a path or `source` in a
subprocess argv, an identifier - lives in code text, which is still scanned in
full, so the guard keeps its reach. test_g9b pins both sides: an
invocation-shaped string in code still trips it (lib.sh and
ensure_subscriber_daemon both reported), a bare lib.sh path in a list literal
still trips it, and the same words in a module docstring, a function
docstring and a comment do not - with hashlib.sha256 present as the original
regression.
Rephrasing the docstring to dodge the literal was the other option and was
not taken: it would hide a useful cross-reference to satisfy a matcher, which
is the evasion shape this guard should not encourage, and it would leave the
next honest comment to trip the same wire.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Review round 3 (planner-reviewer-codex-01, reviewer-opencode-01): R1 blocking,
R2 and the "ONE builder" wording non-blocking. All three reproduced; none
rebutted.
R1. _wait_started still returned 0 as soon as the record left `pending`,
ahead of the shared predicate, so `error`, `cancelled` and another dispatch's
`running` all acknowledged an injection that create's delegate_wait_started
rejects - the two contracts the ack-finding brief said must not diverge. Both
reviewers reproduced it with the real producer and no record manipulation;
my S7n had hidden it by resetting the record to `pending`.
The bypass and the console grep are gone. The wrapper's evidence is now
delegate_started_recorded alone, the same call create makes. For a remote
agent - no shared disk, so no line in events.log - the predicate reads the
subscriber's audit record of the `started` it received. That record is
attributable by construction: the subscriber verifies the HMAC and drops any
event whose data.dispatch_token differs from the job's expected token before
it writes anything. The token itself cannot go into the record (the audit
redactor masks token-named keys), so the subscriber now stamps a boolean
dispatch_verified, and with a token demanded only a verified record counts;
an untokened legacy record fails closed. The registry status is never
evidence; a terminal job is preserved by the terminal logic, not turned into
an acknowledgement.
Tests: W1/W2/W3 are the reviewers' three negatives and the positive, real
producer, record left exactly as it produced it; W4 runs create's wait and
the wrapper's wait side by side on all four and requires the same answer;
W5 pins the remote route (a verified record counts, an unverified or
non-started one does not); W6 drives the real _Watcher and shows it stamps
the flag on a matching started and records nothing for a mismatch; S7n no
longer resets the record. test_issue14's "registry ack over unconfirmed
inject" produced its ack with a bare status flip - the very bypass - and now
produces it the way an agent does, publish_event with the dispatch token;
its scenario and every other assertion are unchanged. test_issue18's remote
e2e accepted the status messages and now expects the shared one.
R2. _code_only blanked a docstring's whole line range, so code sharing that
line - `"""doc"""; call()` - vanished from the scan. It now removes only the
docstring node from the AST and unparses the rest (comments drop out with
it, code strings stay); test_g9b pins the four same-line shapes.
The builder comment claimed one builder for create and the wrapper; the
wrapper keeps _pub_cmd and S7l pins the two equal argument-for-argument. The
comment now says that.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Review round 4 (planner-reviewer-claude-01 B-1, planner-reviewer-codex-01
P2, reached independently): dispatch_verified is a boolean. It says the
subscriber's token check ran when the record was written, but names no
dispatch, the token being masked by the audit redactor and attempt_id not
written. events.ndjson is appended to across a job's attempts, so a started
verified under attempt 1 satisfied every later attempt's wait - the very
misattribution R1 forbids, alive on the remote path while the local path
(data.dispatch_token in events.log) was already immune. codex reproduced it
with the real signed producer, the real _Watcher, the real audit writer and
the real operator re-dispatch; both waits answered 0 where 1 was due, even
when attempt 2's subscriber had correctly dropped attempt 1's replayed packet.
The subscriber now writes dispatch_fingerprint = SHA-256 of the token it
verified, and attempt_id for readers. With a token demanded, the predicate
accepts an audit record only if it is dispatch_verified AND its fingerprint
equals SHA-256 of that token; a record with no fingerprint - written before
the field existed - names nothing and is rejected on that path. Without a
token, any received started counts, as before.
Fingerprint over attempt_id as the identity, weighing the two proposals: the
local path already compares the token, so hashing it keeps one identity axis
for both sources; the token is a uuid4 nonce (registry.py:141), 128 bits, so
its SHA-256 is not recoverable and it is a CAS fence rather than the HMAC
secret in the first place; and attempt_id cannot tell apart a token
re-minted within one attempt, where the token can. attempt_id is recorded
because it is cheap and readable, not relied on.
W7 pins codex's five cases end to end - current dispatch accepted, an old
attempt's record rejected after a real re-dispatch, still rejected when the
stale packet is replayed and dropped, the new dispatch's own started
accepted, a bad HMAC never recorded - with create's wait and the wrapper's
wait required to agree on each. W8 pins the fail-closed legacy record and
the untokened compatibility path. W5 gains the no-fingerprint and
other-dispatch cases; W6 checks the real _Watcher writes the fingerprint and
never the token. S7e's status reset had no reason left and is gone. codex's
own reproduction file, unmodified, passes 5/5 against this tree.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Two probes from the merge-side codex review (its report was lost to an API
outage; the probes and their results were recovered from its transcript):
- create_session.sh and resume_session.sh set AUTO_TRUST=1 and then
exported MAM_AUTO_TRUST from it, so the documented MAM_AUTO_TRUST=0
without a flag reached handle_startup_dialogs as 1: the operator had
turned automatic trust off and it stayed on. Now AUTO_TRUST defaults to
the environment value and an explicit --auto-trust/--no-auto-trust wins.
- _mam_dialog_pick exited on the first dialog inside its resend cooldown
instead of looking further down the screen, so a skipped (--no-auto-trust)
or just-answered trust prompt hid the codex update notice below it, which
the docs say is always answered. It continues to the next dialog now; S5
and S5b (one send per cooldown, one retry after it) still hold.
Regressions: S6c, S5f (startup path), R3b and C3 (resume/create, env only,
flag over env). Each fails without the fix. The create skill doc names the
environment form and its precedence.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
Godopu
merged commit 1debc7d924 into main2026-09-23 23:36:37 +00:00
병합 워크스페이스 재검토 (PR head 를 자체 venv 가 있는 detached 워크트리에 받아 --worktree --base --review-only --all-reviewer):
1회차 @f4ba205: claude·agy·grok PASS. codex 는 선택 테스트 247건 통과 후 탐침 2건으로 NOT PASS 를 쓰던 중 호스트가 반복 절전(pmset 로그: 15분 간격 Maintenance Sleep)에 들어가 API 스트림이 끊겨 리포트를 남기지 못했다. 탐침과 결과는 codex 대화 기록에서 복원했다.
P1: create_session.sh/resume_session.sh 가 AUTO_TRUST=1 을 고정한 뒤 export MAM_AUTO_TRUST 하므로, 문서가 약속한 MAM_AUTO_TRUST=0(플래그 없이)이 핸들러에 1 로 도달 — 자동 신뢰를 껐다고 믿지만 켜져 있었다.
P2: _mam_dialog_pick 이 쿨다운에 걸린 다이얼로그에서 sys.exit(1) 해 같은 화면의 다음 다이얼로그(codex 업데이트 안내)를 답하지 않았다 — --no-auto-trust 로 신뢰 창을 건너뛴 직후, 또는 신뢰 창에 답한 직후.
정정 05b0ea4: AUTO_TRUST="${MAM_AUTO_TRUST:-1}"(명시 플래그 우선), picker 는 continue. 회귀 S6c·S5f·R3b·C3, 각각 수정 없이는 실패.
2회차 @05b0ea4: claude·agy·grok·codex 4인 전원 PASS(identity 4건 base f4ba205 → head 05b0ea4, evidence review-tree; codex 는 두 정정을 되돌린 사본에서 새 회귀 실패를 역검증).
전체 스위트 @f4ba205: 1111 통과 / 1 skip / 실패 0. @05b0ea4 관련 스위트 123 통과.
비차단 후속: 감사 기록의 새 필드(dispatch_verified·dispatch_fingerprint·attempt_id)를 MESSAGING.md·delegate-job SKILL.md 에; MAM_INJECT_ACK_GRACE_S 가 create 대기에도 2순위로 작용한다는 점; MAM_AUTO_TRUST 는 0 만 끈다는 점과 플래그 우선 규칙을 resume SKILL.md 에도; codex 업데이트 안내에 2(이번만) 대신 3(다음 버전까지)을 고르면 매 시작마다 다시 뜨지 않음; test_issue24_resume_dialogs.py 4건 각 ~40 s.
운영 메모: 리뷰 루프가 도는 동안 호스트가 잠들면 에이전트의 API 스트림이 끊기고(codex "Connection reset by peer", claude "computer went to sleep mid-response") 잡의 heartbeat 는 계속 나가 무응답으로 잡히지 않는다(#30 의 사례). 병합 워크스페이스에서는 caffeinate -dims 로 우회했다.
## 병합 완료 — main `1debc7d` (v4.7.2, Gitea merge 커밋)
**병합 워크스페이스 재검토** (PR head 를 자체 venv 가 있는 detached 워크트리에 받아 `--worktree --base --review-only --all-reviewer`):
- 1회차 @f4ba205: claude·agy·grok PASS. codex 는 선택 테스트 247건 통과 후 탐침 2건으로 NOT PASS 를 쓰던 중 **호스트가 반복 절전**(pmset 로그: 15분 간격 Maintenance Sleep)에 들어가 API 스트림이 끊겨 리포트를 남기지 못했다. 탐침과 결과는 codex 대화 기록에서 복원했다.
- P1: `create_session.sh`/`resume_session.sh` 가 `AUTO_TRUST=1` 을 고정한 뒤 `export MAM_AUTO_TRUST` 하므로, 문서가 약속한 `MAM_AUTO_TRUST=0`(플래그 없이)이 핸들러에 `1` 로 도달 — 자동 신뢰를 껐다고 믿지만 켜져 있었다.
- P2: `_mam_dialog_pick` 이 쿨다운에 걸린 다이얼로그에서 `sys.exit(1)` 해 같은 화면의 다음 다이얼로그(codex 업데이트 안내)를 답하지 않았다 — `--no-auto-trust` 로 신뢰 창을 건너뛴 직후, 또는 신뢰 창에 답한 직후.
- 정정 `05b0ea4`: `AUTO_TRUST="${MAM_AUTO_TRUST:-1}"`(명시 플래그 우선), picker 는 `continue`. 회귀 S6c·S5f·R3b·C3, 각각 수정 없이는 실패.
- 2회차 @05b0ea4: claude·agy·grok·codex **4인 전원 PASS**(identity 4건 base f4ba205 → head 05b0ea4, evidence review-tree; codex 는 두 정정을 되돌린 사본에서 새 회귀 실패를 역검증).
- 전체 스위트 @f4ba205: 1111 통과 / 1 skip / 실패 0. @05b0ea4 관련 스위트 123 통과.
- 릴리스 `9fb037c`: 4.7.1 → 4.7.2 락스텝, 리포트 7건 + identity 4건 아카이브.
**비차단 후속**: 감사 기록의 새 필드(`dispatch_verified`·`dispatch_fingerprint`·`attempt_id`)를 MESSAGING.md·delegate-job SKILL.md 에; `MAM_INJECT_ACK_GRACE_S` 가 create 대기에도 2순위로 작용한다는 점; `MAM_AUTO_TRUST` 는 `0` 만 끈다는 점과 플래그 우선 규칙을 resume SKILL.md 에도; codex 업데이트 안내에 `2`(이번만) 대신 `3`(다음 버전까지)을 고르면 매 시작마다 다시 뜨지 않음; `test_issue24_resume_dialogs.py` 4건 각 ~40 s.
**운영 메모**: 리뷰 루프가 도는 동안 호스트가 잠들면 에이전트의 API 스트림이 끊기고(codex "Connection reset by peer", claude "computer went to sleep mid-response") 잡의 heartbeat 는 계속 나가 무응답으로 잡히지 않는다(#30 의 사례). 병합 워크스페이스에서는 `caffeinate -dims` 로 우회했다.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Closes #23
Closes #24
오케스트레이터 pane 의 herdr 환경이 격리 서버 조회를 오염시켜 온보딩 프롬프트가 전달되지 않던 문제(#23)와, 시작 차단 모달이 claude 에게만 처리되어 다른 에이전트가 신뢰 창에 갇히던 문제(#24)를 한 묶음으로 해결한다.
변경 요지
#24 — 시작 다이얼로그가 에이전트 무관으로 처리된다
모달의 시그니처와 수락 키를 에이전트 어댑터(
startup_dialogs→MAM_STARTUP_DIALOGS)로 옮기고handle_startup_dialogs를 에이전트 무관으로 만들었다. agy·grok·codex 의 신뢰 창과 codex 0.155.1 업데이트 안내가 모두 처리된다.--no-auto-trust/MAM_AUTO_TRUST=0으로 신뢰 창만 사람에게 남길 수 있다. resume 도 create 와 같은 계약을 쓴다. 수락 판정은 다이얼로그 시그니처가 아니라 준비 토큰으로 한다.--source visible이 수락된 창을 한동안 계속 보여주기 때문이다.#23 — 교차 서버 호출이 호출자 pane 의 스코프를 떨어뜨린다
herdr pane 은
HERDR_WORKSPACE_ID·HERDR_PANE_ID·HERDR_TAB_ID를 내보내는데, 이 값은 발급한 서버에서만 의미가 있다. 다른 서버를 겨냥한 호출에서는 세 값을 제거한다. 호출자 서버는HERDR_SOCKET_PATH의 모양에서 구한다(HERDR_SESSION이 있으면 우선). herdr 0.8.2 는 기본 서버의 pane 에HERDR_SESSION을 내보내지 않으며, 그 pane 이 바로 이 결함이 나오는 곳이다.#23 — 온보딩 전달 판정이 실제 에이전트가 발행하는 것으로 충족된다
전달 확인은 화면이 아니라 프로토콜로 한다.
delegate_publish_cmd가 create 의 온보딩과 delegate-job 래퍼에 같은 발행 명령 모양을 주고,delegate_started_recorded하나를 두 대기 지점이 공유한다. 이미 종료된 잡은 덮어쓰지 않는다. 원격 경로에서는 구독자가 HMAC 과 디스패치 토큰을 검증한 뒤dispatch_fingerprint(검증한 토큰의 SHA256)와 시도 번호를 감사 기록에 남기고, 토큰을 요구하는 판정은 검증 표시와 지문 일치를 함께 요구한다. 신원이 없는 과거 기록은 fail closed 다.#23 — 실패 처리
롤백이 kill 결과를 확인하고
has-session으로 재확인한다. 죽이지 못했으면 행에 그 사실을 적고 exit 43 으로 끝낸다. 온보딩이 확인되지 않아도 세션은 보존한다(MAM_ONBOARD_ACK_S). 살아 있는 에이전트를 느린 온보딩 때문에 죽이는 것은 지키려는 것보다 잃는 것이 크고, 준비되지 않은 TUI 를 진단용으로 보존하는 바로 앞 단계의 규칙과도 어긋난다.검증
f4ba205)087eaca..f4ba205)실패 12건은 모두 이 호스트의 기존 기준선 실패(
tests/test_issue10_13_purge.py)이며 base 에서도 동일하게 실패한다. 새 실패는 없다.실제 herdr 시험은 오케스트레이터 pane 의 상속 환경을 일부러 그대로 둔 채, 고정된 detached 사본의 코드로 격리 서버에 네 에이전트를 순차 생성해 수행했다. 네 건 모두 사람 개입 없이 TUI 가 준비되고, 온보딩 프롬프트가 전달되고, 잡이
started(디스패치 토큰 포함) 에서completed로 끝났다. grok 의 텔레메트리 opt-in 배너는 입력을 막지 않음을 실측해 다이얼로그 항목을 추가하지 않았다.리뷰에서 나온 차단 지적과 대응
시험과 리뷰 다섯 라운드에서 차단 지적 세 건이 나왔고 모두 해소했다. 세 건 모두 같은 뿌리를 가진다. 테스트 픽스처가 실제 생산자와 달라서 결함을 가렸다.
HERDR_SESSION에만 의존해 이 호스트에서 무력했다. 실제 herdr 시험에서 드러났다.data.dispatch_token을 요구했으나 에이전트가 쓰는publish_event.py는 그 필드를 쓰지 않았다. 픽스처가 그 줄을 손으로 만들면서 토큰을 넣어 통과시키고 있었다. 온보딩을 완료한 에이전트가 180초 뒤 미확인으로 판정됐다.pending으로 되돌려 우회를 가렸다. 이어서 원격 감사 기록이 신원 없는 불리언만 남겨 이전 시도의 증거가 다음 시도를 만족시켰다.픽스처는 이제 실제
registry.py로 잡을 등록하고 실제publish_event.py로 이벤트를 발행한다. 마지막 지적은 리뷰어가 제출한 재현 자산을 수정 없이 돌려 확인했다. 수정 전 2건 실패, 수정 후 5건 통과다.별건
리뷰 루프가 diff 를 단일 argv 로 넘겨, 브랜치 diff 가 커널의 단일 인자 상한 131,072 바이트를 넘으면
OSError: [Errno 7]로 죽고 종료 코드 0 으로 끝난다. 수집기 상한(MAM_DIFF_MAX_BYTES, 기본 200000)이 커널 상한보다 높아 걸러지지 않는다. 이 PR 의 4·5차 리뷰는 델타 범위로 우회해 수행했다. 별도 이슈로 등록한다.🤖 Generated with Claude Code
Two defects in the started-based verdict, both found by the full suite. The first was destructive. No `started` within T exited 1, which fired the rollback trap and killed the session. That is worse than the bug being fixed: create_session already has a rule for "the agent is not responding but the process is alive" one step earlier - a TUI that never signalled ready is preserved for diagnosis - and an onboarding that is merely slow is the same situation. The job is marked `error` and warned about loudly; the session, its row and the pane stay. MULTI_AGENT_RULES §6 has the GM gate on this job anyway, so an unacknowledged onboarding still blocks real work. It also broke 21 tests across 8 modules (create/sanity/tier2/tier3/tier4/ issue15/issue19/issue21) and added roughly an hour to the suite: no sandbox has an agent to publish `started`, so every create_session test sat out the full 180 s and then failed on the rollback. MAM_ONBOARD_ACK_S shortens that wait, and the sandbox fixture sets 3 s. It is deliberately not the wrapper's MAM_INJECT_ACK_GRACE_S - that is a different wait whose own tests assert on the value they pass, and reusing it made issue18's acceptance2 read "within 3s" instead of "within 4s". The second: the delivery failure published `error` and then the rollback published `cancelled` on the same job, which the registry refuses ("conflicting cancelled after terminal error"). Terminal events are published exactly once, so the trap now skips a job this run already closed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>병합 완료 — main
1debc7d(v4.7.2, Gitea merge 커밋)병합 워크스페이스 재검토 (PR head 를 자체 venv 가 있는 detached 워크트리에 받아
--worktree --base --review-only --all-reviewer):create_session.sh/resume_session.sh가AUTO_TRUST=1을 고정한 뒤export MAM_AUTO_TRUST하므로, 문서가 약속한MAM_AUTO_TRUST=0(플래그 없이)이 핸들러에1로 도달 — 자동 신뢰를 껐다고 믿지만 켜져 있었다._mam_dialog_pick이 쿨다운에 걸린 다이얼로그에서sys.exit(1)해 같은 화면의 다음 다이얼로그(codex 업데이트 안내)를 답하지 않았다 —--no-auto-trust로 신뢰 창을 건너뛴 직후, 또는 신뢰 창에 답한 직후.05b0ea4:AUTO_TRUST="${MAM_AUTO_TRUST:-1}"(명시 플래그 우선), picker 는continue. 회귀 S6c·S5f·R3b·C3, 각각 수정 없이는 실패.f4ba205→ head05b0ea4, evidence review-tree; codex 는 두 정정을 되돌린 사본에서 새 회귀 실패를 역검증).9fb037c: 4.7.1 → 4.7.2 락스텝, 리포트 7건 + identity 4건 아카이브.비차단 후속: 감사 기록의 새 필드(
dispatch_verified·dispatch_fingerprint·attempt_id)를 MESSAGING.md·delegate-job SKILL.md 에;MAM_INJECT_ACK_GRACE_S가 create 대기에도 2순위로 작용한다는 점;MAM_AUTO_TRUST는0만 끈다는 점과 플래그 우선 규칙을 resume SKILL.md 에도; codex 업데이트 안내에2(이번만) 대신3(다음 버전까지)을 고르면 매 시작마다 다시 뜨지 않음;test_issue24_resume_dialogs.py4건 각 ~40 s.운영 메모: 리뷰 루프가 도는 동안 호스트가 잠들면 에이전트의 API 스트림이 끊기고(codex "Connection reset by peer", claude "computer went to sleep mid-response") 잡의 heartbeat 는 계속 나가 무응답으로 잡히지 않는다(#30 의 사례). 병합 워크스페이스에서는
caffeinate -dims로 우회했다.