Compare commits

...
261 Commits
Author SHA1 Message Date
Godopu 0e1e2e87de merge: fix/issue-71 (attach uses the herdr-registered name; sessions start and stop only through the skills; v4.11.0) 2026-10-26 22:28:51 +00:00
GodopuandClaude Opus 5.5 342a9a4edf release(v4.11.0): attach uses the herdr-registered name; sessions start and stop only through the skills (Issue #71) - archive the review reports, bump the lockstep version
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-10-27 07:28:47 +09:00
GodopuandClaude Opus 5.5 5f6065344f fix(lib.sh): load_state_json strict treats a missing DB table as unreadable (Issue #71)
A DB without its sessions table (or without both tables) was read as an
empty registry in strict mode, so stop closed a registered session
row-less and exited 0. Strict mode now fails on any OperationalError;
the non-strict reader is unchanged.

Refs #71

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 22:15:02 +00:00
GodopuandClaude Opus 5.5 a47a4683f3 test: review round 2 of Issue #71 (line breaks, unreadable registry, shim anchor)
- parity and create with real line breaks in the name;
- stop with malformed YAML (no DB) or a corrupt DB refuses (exit 1);
- test_layout: the shim heredoc anchor follows the { ... } > "$tmp_file"
  form (the rule it checks is unchanged);
- S11: base re-pinned to 0fc162e, which changes the shim's sanitize body.

Refs #71

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 19:31:18 +00:00
GodopuandClaude Opus 5.5 8b829647f4 fix(stop): a registry that cannot be read is not "no row" (Issue #71)
load_state_json printed {} on a YAML/DB read error, so a registered
session was closed row-less (exit 0) and its stop workflow skipped.
stop now reads the registry strictly and exits 1 before any herdr call.
Pins of the shifted stop_session.sh lines updated.

Refs #71

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 19:31:18 +00:00
GodopuandClaude Opus 5.5 0fc162e8cd fix(lib.sh): line breaks in a name become '-' like Python's; load_state_json strict (Issue #71)
- The ASCII branch of _sanitize_herdr_agent_name translates line breaks
  before sed (line by line) and $(...) (drops a trailing one) can lose
  them: "newline-creator-claude\n" was started as newline-creator-claude
  while its attach_command named newline-creator-claude-.
- `load_state_json strict` exits 1 (stderr) when the YAML or DB cannot be
  read instead of printing {}; without the argument nothing changes.

Refs #71

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 19:31:10 +00:00
GodopuandClaude Opus 5.5 a6fb39ba9f test: review round 1 of Issue #71 (names, row-less stop server, migration retry)
- create with a non-ASCII name: the attach target is the name herdr has;
  bash/Python parity for non-ASCII and option-like names; no python3 is
  an error for a non-ASCII name.
- row-less stop without --herdr-session uses HERDR_SESSION_NAME; an
  unreadable row is not taken for no row.
- a retry (entry point, or any write) repairs a YAML a failed migration
  left in format 1.
- tier3: the "cannot infer agent" case registers its row, as tier1 does.
- S11: base re-pinned to 5cd72d2, which changes the shim's sanitize body.

Refs #71

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 17:35:58 +00:00
GodopuandClaude Opus 5.5 9410913da9 docs(stop): exit 1 also covers a row that could not be read (Issue #71)
Refs #71

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 17:35:58 +00:00
GodopuandClaude Opus 5.5 bd61ef532f refactor(stall_watch): HerdrSource needs the herdr name, no fallback to the original (Issue #71)
Refs #71

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 17:00:25 +00:00
GodopuandClaude Opus 5.5 015075077c fix(registry): a write repairs a YAML that a failed migration left in format 1 (Issue #71)
The writer commits the migrated DB before it replaces the YAML. If that
replacement failed, the DB said format 2 and the next run did nothing.
When the DB is current the writer now also reads the YAML's format and
rewrites a YAML that is older or missing.

Refs #71

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 17:00:25 +00:00
GodopuandClaude Opus 5.5 263a72483a fix(stop): a row-less stop uses the environment's server; only "no row" is row-less (Issue #71)
With no --herdr-session the server is chosen as create_session.sh chose
it (HERDR_SESSION_NAME, else the workspace's), not always derived from
the workspace. The row-less path runs only when the lookup says the row
is missing (exit 7); any other lookup failure stops with exit 1 as
before. Pins of the shifted stop_session.sh lines updated.

Refs #71

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 17:00:25 +00:00
GodopuandClaude Opus 5.5 5cd72d2df3 fix(lib.sh): the herdr name of a non-ASCII or option-like name equals Python's (Issue #71)
tr/sed lowercase differently from str.lower() outside ASCII, so a name
like "İstanbul-creator-claude" was started as x--stanbul-... while the
row's attach_command named i-stanbul-.... A non-ASCII name is now
lowercased and replaced by python3 (fail closed without it), and the
name is read with printf, not echo ("-n" no longer vanishes).

Refs #71

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 17:00:17 +00:00
GodopuandClaude Opus 5.5 e4c7c8fe9b fix: attach_command from one builder; no kill/start_command; stop closes a row-less session (Issue #71)
- attach_command is `herdr --session <server> agent attach <herdr name>`
  (`default` written too), built by lib_py.agents.sanitize.attach_command;
  create, resume and reconcile all use it. kill_command and start_command
  are no longer written, and status --json drops start_command.
- The two raw kill-session hints (create exit 43, id_order's orphan) print a
  stop-skill command with --herdr-session instead.
- stop_session.sh closes a session that has no registry row, herdr only and
  without --agent, when herdr runs it inside this workspace; another
  workspace's session is refused (exit 5), a missing one is exit 1, one that
  survives kill-session is exit 6. The registry is not changed.
- Docs: only the SKILL.md lines these changes made wrong; the skill block
  pins follow. Tests that asserted the removed fields keep their intent
  (issue50: create's own row; issue21: no control-root path in
  attach_command; tier2: the new form and the absent fields). S11 pins the
  shim to 4243da5, which changed it on purpose.

Refs #71

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 15:16:53 +00:00
GodopuandClaude Opus 5.5 9c21fa4e4f feat(registry): format version 2, migrated by the writer (Issue #71)
- lib_py/registry_format.py: a pure, idempotent migrate() drops
  kill_command/start_command and rebuilds attach_command on every row
  (stopped/terminated included) and sets the top-level registry_format key.
- atomic_dump_yaml migrates an older registry under its lock, before the
  caller's mutation, and then rewrites the YAML as well as the DB (the YAML
  write is otherwise skipped when only fields change). load_state_json stays
  read-only. New registries are written with the current version.
- Entry point for #72: `python -m lib_py.registry_format --yaml <path>` runs
  the migration through the writer and prints
  `registry-format: <from> -> <to>, rows changed: <n>`.

Refs #71

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 15:16:46 +00:00
GodopuandClaude Opus 5.5 50e2998a20 refactor(stall_watch): work out the herdr name once, in StallWatch (Issue #71)
StallWatch converts --target once and hands the name to HerdrSource, so
agent get/read/prompt and agent wait cannot call herdr by different names.

Refs #71

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 15:16:41 +00:00
GodopuandClaude Opus 5.5 4243da507c fix(lib.sh): one herdr name rule; the shim's agent attach resolves its target (Issue #71)
- One _sanitize_herdr_agent_name: the shim gets lib.sh's definition through
  `declare -f` instead of a second copy. Without shasum/sha1sum/openssl it
  hashes with python3 (same result as lib_py.agents.sanitize); with none of
  them it fails instead of truncating or appending 00000000.
- Every caller stops on that failure: new-session before any pane is made,
  the lookups report failure, and has-session takes the name lib.sh computed
  (the cwd-relative import and its lower()[:32] fallback are gone).
- `herdr agent attach <name>` through the shim resolves the target like
  get/read/prompt; options after the target are kept.

Refs #71

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 15:16:20 +00:00
Godopu 29cc4fd904 merge: fix/issue-66 (stall_watch calls herdr with the sanitized agent name; v4.10.1) 2026-10-25 05:07:20 +00:00
GodopuandClaude Opus 5.5 04e32212f7 release(v4.10.1): stall_watch calls herdr with the sanitized agent name (Issue #66) - archive the review reports, bump the lockstep version
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-10-25 14:07:15 +09:00
GodopuandClaude Opus 5.5 bb496fde78 fix(stall_watch): call herdr with the sanitized agent name (Issue #66)
herdr registers an agent under sanitize_herdr_agent_name(session), so a
session name over 32 characters or with upper-case letters got
agent_not_found and stall detection went off. stall_watch now converts the
name for every herdr call (agent get/read/prompt, watcher.wait_once); logs
and notices keep the original name, and the agent kind is still read off
the original suffix (a long name's sanitized form ends in a hash).

The conftest mock herdr gets an opt-in strict_agent_names mode that, like
the real herdr, answers an agent only under its registered name and fails
get/read with agent_not_found JSON; existing tests see no change.

Refs #66

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 13:47:54 +00:00
Godopu 0461a632eb merge: fix/issue-63 (same-type agents in one cwd never take each other's conversation id; v4.10.0) 2026-10-23 06:46:12 +00:00
GodopuandClaude Opus 5.5 0454fe968b release(v4.10.0): same-type agents in one cwd never take each other's conversation id; same-cwd creation order (Issue #63) - archive the review reports, bump the lockstep version
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-10-23 15:46:08 +09:00
GodopuandClaude Opus 5.5 1614af5b67 fix(id_order): a dead-owner marker on the caller's own session follows the orphan rules (Issue #63)
A marker left on the invoking session name by a killed create/resume was
pruned before herdr was asked, so a retry under the same name took it over
while the orphaned pane was still alive (and create's failure cleanup could
then kill it). It is now kept and blocks like any other orphan until its
row is registered for the attempt or herdr reports the session gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 11:47:04 +09:00
GodopuandClaude Opus 5.5 7cac6f613e fix(id_order): server_not_running is unknown and cap 0 asks no herdr (Issue #63)
PR #69 review round 3 (rebuttal overruled; codex r2 applied as written).

- A failed `agent list`, herdr's server_not_running included, is unknown and
  keeps an orphaned marker; only a successful list without the name prunes.
- No probe floor: probes get only the time left until the wait deadline, so
  MAM_ID_ORDER_WAIT_S=0 asks herdr nothing (unknown, kept) and the refusal
  says so.
- The timeout message reports the last definite answer of this wait, so an
  alive orphan whose final probe ran out of time still reads as alive.
- Tests follow: server_not_running and cap 0 keep the marker and refuse;
  delayed-probe bound is the cap itself; probing tests use a small cap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:24:39 +09:00
GodopuandClaude Opus 5.5 8f64174b6b fix(id_order): prune an orphaned marker only on an authoritative "gone", within the wait cap (Issue #63)
PR #69 review round 2.

- The herdr probe is tri-state (present / absent / unknown) from a parsed
  `_herdr agent list`: absent only when the list parses without the name or
  herdr answers server_not_running. A failed, unparseable or timed-out lookup
  is unknown and keeps the marker (codex P1: has-session's exit 1 meant both
  "absent" and "lookup failed").
- Probes run with MAM_NO_AUTOSTART=1 so a check never starts a server (agy P2).
- Probe time is capped by max(deadline, start + PROBE_FLOOR_S=2 s), shared by
  all markers, so a slow herdr cannot hold the wait or its lock past the cap
  (codex P2); MAM_ID_ORDER_WAIT_S=0 still gets one bounded look.
- Tests: unknown probe keeps and refuses; delayed probe at caps 0/1/3 stays
  within the bound; real probe via lib.sh mam_id_order_wait against mock herdr
  (listed / unlisted / failing list / server_not_running).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:07:54 +09:00
GodopuandClaude Opus 5.5 ede9c62be3 fix(id_order): keep an orphaned marker blocking while its herdr session is alive (Issue #63)
A create/resume killed after spawning its pane but before registering its
row left a marker whose owner pid was dead; the next same-cwd waiter pruned
it on the pid alone and could start a second session that discovers the
same workspace conversation id (PR #69 review P1).

- Markers record the herdr server. A dead owner's marker is pruned only once
  its row is registered for the attempt (running, newer epoch: the row
  decides) or `_herdr has-session` reports the session gone; an unanswered
  probe counts as alive. On timeout the kill-session command is printed.
- A live marker of another process for the waiter's own session name is kept
  and blocks instead of being deleted; the waiter's own pid's markers go.
- Tests: orphan / registered / gone / same-session / missing pane pid cases,
  and a create-level reproduction against mock herdr (fails before the fix).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 07:39:43 +09:00
GodopuandClaude Opus 5.5 a907cc5294 chore(stop,resume): drop what Issue #63 left unused (Issue #63)
- workspace_uuid.py: the unused sys import.
- stop_session.sh: the orphan CAPTURE_ID and the blank padding that kept
  later line numbers; the inventory pins follow the move instead.
- lib.sh: capture_conversation_id, which nothing calls any more.
- resume SKILL.md: the flow names the session-id decision (session_ids.py),
  not resolve_session_id.sh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 23:53:10 +00:00
Godopu d115bf7156 fix(id_order): reserve startup when resume resolves an unrecorded uuid (Issue #63) 2026-10-21 19:48:44 +00:00
Godopu c747374aa6 fix(session_ids,id_order): optional --session resolution, fresh-marker retention, and canonical cwd matching (Issue #63) 2026-10-21 18:24:14 +00:00
Godopu 8c9a68cf1c docs(stop,resume,create): the lines that became wrong (Issue #63) 2026-10-21 16:28:34 +00:00
Godopu e594d2b650 feat(create,resume): same-cwd ordering of post-discovered agents (Issue #63) 2026-10-21 16:28:34 +00:00
Godopu fa93cc5622 fix(resume): no guess with two same-type rows in a cwd; refuse another row's id before spawning; the running branch uses the same decision (Issue #63) 2026-10-21 16:28:34 +00:00
Godopu 3a2219454e fix(stop): record and purge only the row's own or a verified id; a dead herdr records none (Issue #63) 2026-10-21 16:28:34 +00:00
Godopu b8a30d5b93 feat(agents): session_ids - the stop/purge/resume id decision (Issue #63) 2026-10-21 16:28:34 +00:00
Godopu ead490bddc refactor(workspace_uuid): find_workspace_uuid() returns the id and skips exclude_ids; main prints it (Issue #63) 2026-10-21 16:28:34 +00:00
Godopu dc886ecd6b merge: fix/issue-40-auto-pin (the side that verifies a completed pins post-discovered session ids; v4.9.9) 2026-10-20 17:12:55 +00:00
GodopuandClaude Opus 5.5 0d5f10a546 release(v4.9.9): the side that verifies a completed pins post-discovered session ids; agy never takes another workspace's conversation (Issue #40) - archive the review reports, bump the lockstep version
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-10-21 02:12:38 +09:00
GodopuandClaude Opus 5.5 84af709bb3 test(issue42): follow the SKILL.md and reconcile.sh line moves of the Issue #40 change
The skill-block inventory recorded line numbers that the auto-pin change moved:
one create block, two monitor blocks, and the reconcile.sh evidence for the
drift A example (601 -> 593) and the status --json example (1084, past the new
end of the file -> 816).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-10-21 02:11:14 +09:00
GodopuandClaude Opus 5.5 feccfd5e03 fix(agents): pin post-discovered session ids on a verified completed; agy never takes another workspace's or session's conversation (Issue #40)
- BaseAgentAdapter: id_lifecycle (pre_assigned: claude, grok / post_discovered)
  and capture_active_session_id - exactly one verified candidate newer than the
  session that no other row records, else a reason (no-candidate, ambiguous, ...).
- agy: verify_artifact (discover) rejects an id last_conversations.json maps to
  another workspace and always excludes ids other rows record (a cache hit no
  longer bypasses it). capture uses the cache only as a proposal: the
  conversation's presence lock or DB must be held by the row's pane process or
  a descendant (lsof/ps); anything unprovable is not pinned.
- lib_py/agents/autopin.py: handle_completed, run by the side that verified the
  job's completed - the delegate-job wrapper (direct), create --onboard and the
  loop's wait_for_job (recovery, as a subprocess: G9). Local herdr jobs only;
  one (job, dispatch token, attempt) is handled once (job-scoped flock + marker
  in the job dir); writes go through atomic_dump_yaml; a pin records
  session_id_source=discovered / session_id_verified=true; a failure leaves the
  reason on the row and never changes the job's result.
- reconcile.sh drift C for agy, hermes, opencode and codex calls the same
  capture (the agy *.db glob is gone); _pin_and_verify_resume uses pin_row.
- Tests: tests/test_issue40_auto_pin.py; issue64 :407 now expects False; the
  static reconcile-source checks follow the move to the adapters; the two tier2
  drift-C component tests create with --no-onboard (onboarding now pins).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:40:03 +09:00
Godopu ff8b381520 merge: fix/issue-64-ro-connect (agy, codex and hermes read their databases read-only; v4.9.8) 2026-10-20 00:38:12 +00:00
GodopuandClaude Opus 5.5 38ea4f3b8e release(v4.9.8): agy, codex and hermes read their databases read-only (Issue #64) - archive the review reports, bump the lockstep version
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-10-20 09:37:53 +09:00
Godopu b0eccc5301 fix(agents): open agy, codex and hermes databases read-only (Issue #64) 2026-10-19 23:12:55 +09:00
Godopu 3ce5d5fdc1 merge: fix/issue-60-linux (opencode 2.x Linux follow-up; v4.9.7) 2026-10-19 03:16:21 +00:00
GodopuandClaude Opus 5.5 cffe279eb0 release(v4.9.7): opencode 2.x Linux follow-up (Issue #60, PR #62) - archive the review reports, bump the lockstep version
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-10-19 12:16:20 +09:00
GodopuandClaude Opus 5.5 6a37c0019b fix(opencode): Mac review of the Linux fixes - a clean db has no sidecars on Mac either, the placeholder needs its example, a home install must run, status and reconcile read the db read-only (Issue #60, PR #62)
- tests: macOS SQLite keeps -wal/-shm after the last close (persistent WAL);
  the `clean` state removes them after the checkpointing close
- the 2.x placeholder pattern requires the quoted example, so a typed
  `Ask anything…` is read as a draft
- auth_ok takes ~/.opencode/bin/opencode only as an executable file
- status.sh and reconcile.sh (drift C) open opencode.db with _ro_connect

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-10-19 10:57:40 +09:00
GodopuandClaude Opus 5.5 6a4e385a78 test(opencode): the narrow 2.x capture ends at its last drawn line (Issue #60, review: git diff --check)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 13:38:58 +00:00
GodopuandClaude Opus 5.5 d17cd80bd9 fix(opencode): the auth check is the binary, and create takes the adapter's verdict (Issue #60, Linux item 3)
opencode 2.x needs no login: a fresh HOME runs a free default model and a
local provider has no key, while 2.x keeps no auth.json. auth_ok is now
"opencode on PATH or under <HOME>/.opencode/bin". `python -m lib_py.agents
auth-ok <agent>` exits with an adapter's verdict, and create's opencode
branch uses it (same 7 lines) instead of its own auth.json/key test and
`opencode --version`, which wrote a line to the real opencode log.
test_a4's opencode block now pins the binary rule (decision D1, required
by the brief's item 3 and by both reviewers). Other agents are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 13:38:58 +00:00
GodopuandClaude Opus 5.5 7803d47026 fix(opencode): a -wal left without its -shm is read, not ignored (Issue #60, Linux item 5, review)
_ro_connect read every database without both sidecars as immutable, which
skips a -wal's committed rows: a recovered DB+WAL with no -shm showed no
sessions (planner-reviewer-codex-01). That state - no writer runs without
a -shm - is now read from a private DB+WAL copy, where SQLite may rebuild
the index, loaded into memory; nothing beside the source is touched. The
query matrix gains the walonly state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 13:36:41 +00:00
GodopuandClaude Opus 5.5 463817ee34 fix(stop): an unconfirmed /exit no longer claims a kill-chain fallback (Issue #60, Linux item 2)
An agent that exits on /exit never turns `working`, so send_keys_safe
returns 4 although the stop then ends cleanly. The line now says the
delivery is unconfirmed and that the stop waits for the session to exit;
the kill steps keep their own lines, printed only when they run. Exit
judgement and the kill chain are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 12:35:27 +00:00
GodopuandClaude Opus 5.5 614ab1420f fix(opencode): debug paths runs once per binary and home in a process (Issue #60, Linux item 6)
Every run appends a line to opencode's log, and one discover resolved the
database once per candidate. A successful answer is kept per (binary, HOME,
XDG_DATA_HOME) - the values the run sees; a failed run is not remembered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 12:33:01 +00:00
GodopuandClaude Opus 5.5 ac5f1154c7 fix(opencode): queries open the database read-only without touching its sidecars (Issue #60, Linux item 5)
A read-write open checkpointed the WAL and deleted -wal/-shm on close; a
plain mode=ro open created missing ones and left them. _ro_connect uses the
sidecars only when both exist (a live writer keeps them) and reads the file
as immutable otherwise. verify_artifact, _has_session, discover,
_descendants and residual_artifacts (db_rows_naming takes a connect) use it;
the purge's own writes are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 12:31:44 +00:00
GodopuandClaude Opus 5.5 14d7a46181 fix(opencode): the 2.x home placeholder is not typed input, cut example included (Issue #60, Linux item 1)
An adapter can now name its placeholder as a pattern the box's first line
matches in full (input_placeholder_pattern); input_placeholder stays the
literal lib.sh greps for. opencode: `Ask anything… "<example>"`, the closing
quote optional because a box under ~65 columns cuts the example. Fixtures:
the 140-column herdr capture and a 60-column one with the cut example.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 12:29:54 +00:00
GodopuandClaude Opus 5.5 4d49abb371 fix(tests): u1e hands the wrapper its own python - PATH's python3 may lack paho (Issue #60, Linux item 4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 12:23:35 +00:00
Godopu 1d7b7dab58 merge: fix/issue-60-opencode-v2 (support opencode 2.x only; v4.9.6) 2026-10-18 10:36:17 +00:00
GodopuandClaude Opus 5.5 cb2c84ccd7 release(v4.9.6): opencode 2.x only (Issue #60) - archive the review reports, bump the lockstep version
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-10-18 19:35:48 +09:00
GodopuandClaude Opus 5.5 0e05b1b677 fix(opencode): tests drop XDG_DATA_HOME, status reads the adapter's db, the version probe runs in a home of its own (Issue #60, review 2)
- Review 8a485fa0 B1 / c9d06e85 / c4e96018 (blocking): the adapter now
  follows the caller's XDG_DATA_HOME for its own home, but the tests did not
  drop it: a sandbox that sets HOME resolved the runner's real data dir.
  XDG_DATA_HOME joins _agent_home_guard.DROPPED_VARS (dropped per test), and
  the session guard watches <XDG_DATA_HOME>/opencode when it is set.
  Checked with XDG_DATA_HOME set to a separate dir: the opencode modules
  pass and nothing is written there.
- status.sh asks the adapter for the db (`debug paths`, XDG-aware) instead
  of a fixed ~/.local/share/opencode (8a485fa0 N1).
- The opt-in test's `opencode --version` probe ran with the real HOME at
  import and appended to the real opencode log; it runs in a temporary home
  now (8a485fa0 N2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-10-18 16:36:12 +09:00
GodopuandClaude Opus 5.5 cb5f439cce fix(opencode): a purge takes its child sessions' 1.x rows, the background service alone holds no session, XDG_DATA_HOME is followed (Issue #60, review)
- Review 614b6263 (codex, P2): `opencode session delete` removes the child
  sessions with their parent, but their 1.x rows stayed and the parent's
  residual was empty. The child sessions (session_v2 and a 1.x session
  table, any depth) are taken before the delete; the 1.x tables (message,
  part, todo, session_share, session) are cleaned for all of them, and the
  residual also reports a child still on record (a failed delete).
- The in-use check counted any process holding the shared opencode.db; the
  per-user 2.x background service (`serve --service`) holds it for every
  session, so every opencode purge was refused while it ran (it does on
  this Mac). It no longer counts; any other holder and a command line
  naming the session still do.
- The data dir follows the caller's XDG_DATA_HOME when ctx is the caller's
  own home (else <home>/.local/share), for debug paths, the fallback and the
  residual scan (review 614b6263 / f4cedbae N4).
- resident_children = ('opencode',): the --standalone server stays the TUI's
  idle child; what runs under it still counts (review 1abe0a90).
- Tests: child sessions on a migrated database, a failed delete, the service
  holder, XDG, resident children, drift C pinning the parent over a newer
  child session (review f4cedbae N2), and the real-binary test no longer
  depends on PATH (N1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-10-18 15:46:04 +09:00
GodopuandClaude Opus 5.5 6b4b96fa18 docs(opencode): 2.x only; the TUI takes no -m/--agent; MAM spawns --standalone (Issue #60)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-10-18 14:58:29 +09:00
GodopuandClaude Opus 5.5 f0d1c000f9 test(opencode): the 2.x schema and CLI, a fake opencode, and an opt-in real-binary contract (Issue #60)
tests/_fake_opencode.py implements the two commands MAM calls (debug paths,
session delete) as measured on 2.0.32; unit tests never run a real opencode
(it would migrate the test database). test_real_opencode_2x_contract runs a
real 2.x opencode, when installed, in a home and tmp dir of its own with
--standalone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-10-18 14:58:29 +09:00
GodopuandClaude Opus 5.5 a311c23303 fix(opencode): support opencode 2.x only - session_v2, debug paths, session delete, --standalone (Issue #60)
Measured on opencode 2.0.32 (macOS):
- the TUI rejects --agent ("Unrecognized flag"), so create and resume could
  not start opencode at all; the default agent is build. MAM now spawns
  `opencode --auto --standalone` (resume adds --session <id>): the server
  runs as the TUI's child and ends with it, and the per-user background
  service is neither used nor started;
- sessions live in session_v2 (a fresh 2.x database has no `session` table;
  a migrated one keeps the 1.x tables but no longer writes them). discover,
  verify_artifact, reconcile drift C and status read session_v2, and
  discover / drift C skip child (subagent) sessions;
- `db path` is gone (it prints the help and exits 0): the db comes from
  the `db` line of `opencode debug paths`, else the default path;
- `opencode session delete` removes a session with its child sessions from
  the 2.x tables (and leaves the 1.x ones): purge runs it with --standalone
  for a session in session_v2, still deletes the 1.x message/part/session
  rows, and residual also reports a session_v2 row left by a failed delete.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-10-18 14:58:29 +09:00
Godopu 74333f87d0 merge: fix/issue-31 (a claude purge removes every path the session owns; v4.9.5) 2026-10-17 16:25:20 +00:00
GodopuandClaude Opus 5.5 6fd417a7b7 release(v4.9.5): a claude purge removes every path the session owns (Issue #31, PR #59) - archive the review reports, bump the lockstep version, load logroot eagerly
Review loop (--review-only, --all-reviewer, focused on this Mac:
macOS, /usr/bin/python3 3.9.6, bash 3.2): round 1 (@6f4f40a) agy and
grok PASS, claude and codex NOT PASS (a non-uuid id `memory` purged the
auto-memory dir, a linked dir below a root was followed, a NaN
startedAt counted as dead), sent as a PR comment and fixed by the
author in eleven commits; round 2 (@2a8d738) confirmed all three fixed
but 4/4 NOT PASS on one test holder (`sh -c "sleep 120" <uuid>` loses
the uuid on macOS /bin/sh), fixed in de6f3fc; round 3 (@de6f3fc) 4/4
PASS.

Full suite on macOS / Python 3.9.6 @de6f3fc: 1934 passed, 56 skipped,
1 error (the known #55 false positive: agy conversations of another
workspace). The real ~/.claude, /private/tmp/claude-<uid> and herdr
panes unchanged.

Also names lib_py.delegation.logroot in eager.CORE_MODULES (the v4.9.4
review's non-blocking note).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-10-18 01:25:15 +09:00
GodopuandClaude Opus 5.5 de6f3fc81f test(issue31): the spelling test's holder keeps the uuid in its argv (Issue #31, Mac R2)
macOS /bin/sh (bash 3.2) execs the lone `sleep 120` of `sh -c "sleep 120" <uuid>`,
so the uuid left the process list and both [upper] and [mixed] failed on the Mac.
A Python holder keeps the uuid in argv, like _holder() in test_issue10_13_purge.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-10-17 23:54:30 +09:00
GodopuandClaude Opus 5.5 2a8d73825b test(a4): the composite purge names its claude session by a real uuid (Issue #31, review)
claude now takes only a canonical uuid (81b6c2e), so the fixture id
'uuid-c' purged nothing. The three claude spellings in
test_purge_artifacts_composite become one uuid literal; the assertions
and the other adapters' parts are unchanged. Approved by the user.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 13:43:23 +00:00
GodopuandClaude Opus 5.5 81b6c2ea6b fix(agents): only a canonical uuid reaches any claude path (Issue #31, review)
The user asked for no path at all from an id that is not a uuid. bd09c6a
still took `<id>.jsonl` for one (the pre-#31 reach), which has_artifacts and
the residual counted while in-use always said free: `purge claude notes`
deleted notes.jsonl. Now purge, has_artifacts and residual return nothing
for such an id, as in-use already did.

This makes test_a4_adapter_contract.py::test_purge_artifacts_composite fail:
it purges the claude id 'uuid-c' and expects uuid-c.jsonl gone. Editing an
existing test needs the user's approval; the one-line data change
(a canonical uuid in place of 'uuid-c') is kept outside the branch, in job
90efa4ce's report dir. Reverting this commit restores the jsonl-only reach.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 04:09:32 +00:00
GodopuandClaude Opus 5.5 dd292c12cb fix(agents): a claude leftover behind a link still selects claude for its residual (Issue #31, review)
has_artifacts counted only the paths a purge may remove. When all that was
left lay behind a linked dir, `purge claude <uuid>` (or auto) found no
adapter and exited 5 with an empty residual, hiding the path the purge
had refused to follow. has_artifacts now counts every candidate; _inside
still decides what is removed, so the CLI exits 0, removes nothing and
lists the linked path as residual.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 04:07:49 +00:00
GodopuandClaude Opus 5.5 c0248b88be fix(agents): any spelling of a claude uuid is checked as claude's lowercase id (Issue #31, review)
_is_uuid took an uppercase or mixed-case uuid, and the paths built from it
are the live lowercase session's own files on macOS's default
case-insensitive APFS. The in-use checks compare exactly, though: session
files and command lines carry the lowercase id, so `purge claude <UPPER>`
found no holder and could remove a live session's files. _canonical now
lowercases a canonical uuid, and the paths, the session-file and job
matches, the in-use check and the residual all use it; a command line is
searched for both spellings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 04:06:37 +00:00
GodopuandClaude Opus 5.5 60779ba1f8 fix(agents): a startedAt too large for a float counts as live (Issue #31, review)
json.load reads a huge integer as an int, and math.isfinite raised
OverflowError on it outside any try: conversation_in_use and purge_artifacts
(which must never raise) threw, where 6f4f40a had taken it as live. The
conversion to float now comes first, and an overflow means live.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 04:05:13 +00:00
GodopuandClaude Opus 5.5 e156179822 fix(stop): the exit-4 refusal names where the holder may be (Issue #31, Mac review)
The refusal pointed at ~/.claude/sessions/ for every agent, whatever
CLAUDE_CONFIG_DIR said, and never mentioned a non-agent process naming the
uuid on its command line. The in-use CLI now prints, on stderr while the
conversation is in use, the adapter's in_use_hints: for every adapter
`ps -axww | grep <uuid>`, and for claude its real sessions/ dir first. stop
prints them under its refusal, and says "records preserved" instead of
"no state was modified": the gate runs after the kill chain, so the session
itself may already be down. SKILL.md's exit 4 row says the same; it is not
a code block, so no inventory hash moves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 03:25:10 +00:00
GodopuandClaude Opus 5.5 af8ed397f6 fix(agents): compare a claude process's lstart in UTC (Issue #31, Mac review)
time.mktime read ps's local lstart, which is ambiguous in the hour a DST
change repeats: the start could come out an hour off and a live holder be
taken for a reused pid (or the reverse). ps now runs with TZ=UTC0, which BSD
ps and procps both honour, and calendar.timegm reads it. No /proc.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 03:23:17 +00:00
GodopuandClaude Opus 5.5 f943b0db30 fix(agents): a session file whose startedAt is no finite time is live (Issue #31, Mac review)
float("NaN") and a JSON NaN passed the old check, and every comparison with
NaN is false, so a live holder looked like a reused pid: the in-use gate let
the purge through and the session file was taken as stale. startedAt must
now be an int or float (not bool), finite and positive; anything else cannot
be told and counts as live. A dead pid is still stale whatever it says.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 03:21:50 +00:00
GodopuandClaude Opus 5.5 6e0361cf43 fix(agents): a claude purge never follows a linked dir below its root (Issue #31, Mac review)
A tmp-root entry <key> linking outside made `<key>/<uuid>` look like a plain
directory, and rmtree removed what lay behind the link; projects/<key>,
file-history/ and the other config dirs had no such check either. Each
candidate now keeps the root it was found under (config, projects or tmp),
and is purged only when every component between that root and the leaf is a
real directory and its parent resolves inside the resolved root. A root may
itself be a link (/tmp -> /private/tmp), a leaf link is still removed as the
link alone, and a candidate behind a link is reported as residual instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 03:20:55 +00:00
GodopuandClaude Opus 5.5 bd09c6a133 fix(agents): a claude purge takes directories only for a canonical uuid (Issue #31, Mac review)
projects/<key>/memory/ (Claude Code's auto memory) sits beside the session
dirs, and `purge claude memory` removed it whole. Only an 8-4-4-4-12 hex id
now reaches the session's directories, session files, jobs and tmp dirs. Any
other id keeps the pre-#31 reach, the `<id>.jsonl` file alone (test_a4 still
purges "uuid-c"); its residual is that file and nothing holds it in use.
The shared _plausible_id is unchanged for the other adapters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 03:19:02 +00:00
GodopuandClaude Opus 5.5 26fb1c4130 test(issue31): keep every claude tmp root candidate inside the test
The adapter's fixed /tmp fallback is now a module constant. The new tests
point it, TMPDIR, TMP and TEMP at the test's own dirs, in the test process
and (through a sitecustomize on PYTHONPATH) in every python a test starts,
so no test lists the real /tmp/claude-<uid> any more (Mac review, PR #59).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 03:16:36 +00:00
GodopuandClaude Opus 5.5 6f4f40a4ad test(issue31): stop purges a session kept under CLAUDE_CONFIG_DIR
End to end through stop_session.sh with CLAUDE_CONFIG_DIR alone and with a
matching CLAUDE_PROJECT_DIR; plus the DiscoveryContext default. The
config-only stop case and the default check fail on 872d5f1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:29:16 +00:00
GodopuandClaude Opus 5.5 f0af98971e fix(agents): the claude projects root follows CLAUDE_CONFIG_DIR too (Issue #31, review)
stop resolves the uuid before it purges, through lib.sh's exported
CLAUDE_PROJECT_DIR and DiscoveryContext.claude_dir, both of which defaulted
to $HOME/.claude/projects. With CLAUDE_CONFIG_DIR alone the transcript under
<cfg>/projects was not found, so stop removed the row and purged nothing.
Both defaults are now ${CLAUDE_CONFIG_DIR:-<home>/.claude}/projects; an
explicit CLAUDE_PROJECT_DIR still wins.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:29:16 +00:00
GodopuandClaude Opus 5.5 872d5f12fb test(issue31): CLAUDE_CONFIG_DIR and partial-failure purges
Config-only and config+project overrides: a live session is refused and a
stopped one cleaned up. A key that cannot be removed keeps its session file;
a job dir that cannot be emptied keeps state.json; both are reported and
removed on retry. 5 of these fail on 8edb8d6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:00:40 +00:00
GodopuandClaude Opus 5.5 57830cf00a docs(stop): the workflow and the state diagram say a purge removes the row (Issue #31)
SKILL.md lines 52 and 83 still said a purge leaves a `terminated` row. The
two blocks' inventory shas move with them (#42), and so does the
passthrough line of stop's heredoc SystemExit(1) (310 -> 327, after the
process gate). Line counts are unchanged, so no other block moves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:00:40 +00:00
GodopuandClaude Opus 5.5 f542596975 fix(agents): a claude purge keeps ownership evidence until last (Issue #31, review)
A session file is what ties a pid's key file to the session, and a job's
state.json what ties the job dir: removing them first left the rest
unattributable after a partial failure (residual: none, and a retry could
not find it). Keys now go before their session file, which stays while any
key does; a job dir is emptied with state.json last and kept if anything
stays. residual lists a session file's key files (masked). A startedAt of 0
or less cannot tell a reused pid and counts as live; the sessions/ listdir
no longer raises.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:00:40 +00:00
GodopuandClaude Opus 5.5 8a4f651bdf fix(agents): claude follows CLAUDE_CONFIG_DIR (Issue #31, review)
CLAUDE_CONFIG_DIR moves claude's whole config root, sessions/ included, so a
live session started under it was not seen and a stopped one left
file-history and the rest behind. The config root is now CLAUDE_CONFIG_DIR,
else <home>/.claude; the purge and residual scans look at ctx.claude_dir and
the root's own projects/, each once. The test guard drops the variable for
every test like the other agent-home variables (test_issue55 l1 requires
every *_DIR the adapters read to be accounted for).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:00:40 +00:00
GodopuandClaude Opus 5.5 8edb8d6f0f test(issue31): purge, residual and the stop process gate
17 of the 25 cases fail on 26e2c43; the other 8 pin invariants (other
sessions, shared files, malformed ids, a prefix-twin job dir).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 15:16:06 +00:00
GodopuandClaude Opus 5.5 6328e67469 docs(stop): a purge removes the row; exit 4 covers a live agent (Issue #31)
The prose said a purge leaves a `terminated` row; the code removes it, and
the monitor never marks a stopped row. The step-5 list names what a claude
purge removes. The two code blocks (lines 52, 83) still say `terminated`:
changing them changes their inventory sha, which waits for approval.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 15:16:06 +00:00
GodopuandClaude Opus 5.5 b3461377ae fix(agents): codex, grok and opencode report their residual (Issue #31)
Their purge is unchanged; what it leaves is now reported: codex names and
thread rows (state_*, thread_history_*; logs_* is not scanned), grok
memory-v2 observations, opencode event/todo rows. db_rows_naming opens the
DB read-only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 15:16:06 +00:00
GodopuandClaude Opus 5.5 ae98cf18c4 fix(stop): a purge waits for the agent process, not only the herdr session (Issue #31)
A closed herdr session is not an exited agent, and the `herdr already dead`
path ran no check at all, so stop could remove the files of a live claude
and then its row. New `python -m lib_py.agents in-use <agent> <uuid> [ws]
[--wait s]` gives the purge CLI's own verdict; stop asks it outside the
registry lock and, unless it answers free, exits 4 with nothing changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 15:16:06 +00:00
GodopuandClaude Opus 5.5 48cda51d85 fix(stop): a purge reports its residual like orc_onboard (Issue #31)
stop called purge_artifacts only. It now prints `WARN residual: <path>` for
each leftover or `residual: none (agent: <agent>)`, the strings orc_onboard
prints. The row is still removed and the exit stays 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 15:16:06 +00:00
GodopuandClaude Opus 5.5 366da3b3d6 fix(agents): claude reports what still carries the uuid (Issue #31)
claude had no residual_artifacts, so a purge always printed `residual: none`.
It now reports names holding the uuid under ~/.claude (projects excluded),
in projects two levels deep, in the tmp roots, plus the owned jobs dir and
session files. Shared files (history.jsonl, ~/.claude.json) are not
reported: they always mention every session. scan_names goes to base.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 15:16:06 +00:00
GodopuandClaude Opus 5.5 9ccd3c1835 fix(agents): claude purge removes every path the session owns (Issue #31)
The claude purge removed only <uuid>.jsonl. It now removes everything the
session alone owns: projects/<key>/<uuid>/, file-history/<uuid>/,
session-env/<uuid>/, jobs/<uuid[:8]>/ when its state.json names the session,
a stale sessions/<pid>.json with that pid's key file (name only, never read)
and <tmp>/claude-<uid>/*/<uuid>/. An id that is empty, all dots or holds
/, .. or * builds no path. has_artifacts finds leftovers without the transcript,
and conversation_in_use also sees a live claude through sessions/<pid>.json
(a `claude -c` names no uuid on its command line); a reused pid is told
apart by ps lstart against the file's startedAt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 15:16:06 +00:00
Godopu 26e2c43753 merge: fix/issue-57 (the audit log follows a moved registry; v4.9.4)
Closes #57. Review round 2 (@a7cb90e) 4/4 PASS; full suite on macOS / Python 3.9.6 green apart from the known #55 false positive.
2026-10-15 13:52:09 +00:00
GodopuandClaude Opus 5.5 7cf728e9f8 release(v4.9.4): the audit log follows a moved registry (Issue #57, PR #58) - archive the review reports and bump the lockstep version
Review loop (--review-only, --all-reviewer, focused on this Mac:
macOS, /usr/bin/python3 3.9.6, bash 3.2): round 1 (@63d8164) agy and
grok PASS, claude and codex NOT PASS (F1 paho via the /usr/bin/python3
fallback, F2 logs default path, R1 disk-fallback redispatch
interleaving, R2 symlinked registry), sent as a PR comment and fixed by
the author in four commits; round 2 (@a7cb90e) 4/4 PASS.

Full suite on macOS / Python 3.9.6 @a7cb90e: 1867 passed, 56 skipped,
1 error (the known #55 false positive: WAL/SHM of an agy conversation
running in another workspace). Real herdr panes and ~/.claude/projects
unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-10-15 22:52:04 +09:00
GodopuandClaude Fable 5.1 a7cb90e63a fix(delegation): the sibling rule pairs with the registry's real path (Issue #57, Mac R2)
A registry directory that is itself a symlink got two audit logs: submit
resolved --registry-dir with pwd -P and wrote next to the target, while logs
derived the sibling from the link path. sibling_logs_dir() now takes the
registry's realpath; an explicit DELEGATE_JOB_LOGS_DIR still wins.
Also: forwarded_keys() normalises both sides of its comparison, and the
design doc's status row lists the audit log next to the registry.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-15 00:06:30 +00:00
GodopuandClaude Fable 5.1 1c598bb546 fix(subscriber): the disk fallback re-reads the record before taking an audit status (Issue #57, Mac R1)
The record (status, token, attempt, transition_seq) was read before the
audit status.json; if A error -> redispatch (attempt 2) -> B completed
happened in between, the audit terminal was B's while the token and attempt
validated were A's, and A's caller was told "completed". The audit status
is now taken only when a fresh read of the record still shows the same
token, attempt and transition_seq; otherwise the job stays pending and the
next round reads both afresh. The no-record case is unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-15 00:04:52 +00:00
GodopuandClaude Fable 5.1 151af99765 fix(delegate-job): logs passes --registry-dir on only when given (Issue #57, Mac F2)
cmd_logs always passed the wrapper's cwd-relative default registry, so the
usual run (registry not moved, cwd not the workspace) no longer found its
jobs. Without the option registry.py's own default decides, as before:
MAM_JOBS_DIR, then WORKSPACE_ROOT, then the cwd.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-15 00:02:50 +00:00
GodopuandClaude Fable 5.1 75c004222c fix(delegation): the path rules live in a stdlib-only module; the package imports without paho (Issue #57, Mac F1)
control_plane and status.sh imported sibling_logs_dir from mqtt_common,
which imports paho at module level, so create/resume's binding and status
died on an interpreter without paho-mqtt (_delegate_py_bin's last fallback
is the PATH's python3). The registry/audit-log path rules move to
lib_py/delegation/logroot.py (standard library only) and mqtt_common
re-exports them. The package's own import chain also reached paho through
service -> publish_event -> publisher, so mqtt_common and publisher import
paho tolerantly and make_client() refuses with a clear error when a broker
is actually needed. control_plane, status.sh, lib.sh's delegate_publish_cmd
and delegate_logs_dir, and the wrapper's _logs_dir_abs import from logroot.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-15 00:02:42 +00:00
GodopuandClaude Fable 5.1 63d816468e test(issue-57): the derivable audit-log root is recorded, not forwarded
New: prefix()/CLI forward three keys for a sibling binding and four for an
explicit root; create records the sibling on the row, omits it from the
pane's --env and still pins it in the injected publish commands; the
issue's scene (a pane with WORKSPACE_ROOT and MAM_JOBS_DIR only) moves its
audit log with --registry-dir and leaves the control root alone;
worktree_prepare's pane --env carries no DELEGATE_JOB_LOGS_DIR.

The three existing four-key forwarding assertions (user-approved) compare
FORWARDED_ENV = CP_ENV[:3]; CP_ENV and the negative assertions are kept.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-14 13:11:29 +00:00
GodopuandClaude Fable 5.1 c57e7667aa fix(control-plane): forward only what the pane cannot derive - the sibling audit-log root stays on the row (Issue #57)
A worktree creator's binding always differs from its --workspace default,
so prefix() put DELEGATE_JOB_LOGS_DIR=<control root>/.mam/delegate_job_logs
into every such pane for good, and the env-first rule then kept an
experiment's --registry-dir from moving its audit log. forwarded_keys()
leaves that key out when it equals the sibling of mam_jobs_dir; a
non-sibling (explicit) root is forwarded as before. describe(), the forward
and whitespace checks and the row's four keys are unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-14 13:11:29 +00:00
GodopuandClaude Fable 5.1 c982616188 fix(issue-57): review nits - a damaged audit sequence or an unresolvable log root never aborts the check
_check_disk_fallback converted transition_seq outside its try, so a
status.json whose sequence is not an integer would raise out of the
subscriber's main loop; such a status is now ignored with a warning, like a
stale one. delegate_started_recorded returned 1 before looking at the
registry's events.log when the audit-log root could not be resolved; it now
skips only the audit check.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-13 12:46:50 +00:00
GodopuandClaude Fable 5.1 f299034d8d fix(subscriber): the disk fallback takes an audit status only when it is at least as new as the record
The audit mirror is written after the registry commits, so a redispatched
record (attempt 2, pending) can sit next to attempt 1's completed
status.json, and a failed mirror leaves it there. The fallback validated the
token and attempt against the record, which say nothing about the audit
status's provenance, and closed attempt 2 with attempt 1's completion
(review P1, Issue #57). A versioned record (transition_seq) now takes the
audit status only when its transition_seq is at least as new; an audit
status without a sequence cannot prove which attempt it describes and is
ignored by a versioned record. An unversioned record (never transitioned,
or MAM_ENGINE_CAS=0) and a missing record keep the old rule.

Regressions: the redispatch/flush interval, the failed-mirror leftover, the
unversioned audit status, a newer audit-only terminal, and conflict
refusals and legacy claims next to a moved registry.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-13 11:47:17 +00:00
GodopuandClaude Fable 5.1 6d182b392c fix(delegation): conflict refusals and legacy claims write next to the registry too
engine._flush computed the registry's audit-log root but did not pass it to
the conflict_refused append, and _pick_pending_legacy did the same for its
status_changed append, so both landed in the workspace default when the
registry was moved and no override was set (review P2, Issue #57).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-13 11:47:17 +00:00
GodopuandClaude Fable 5.1 642a53b716 docs(delegate-job): the audit log is next to the registry unless DELEGATE_JOB_LOGS_DIR moves it
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-13 10:02:08 +00:00
GodopuandClaude Fable 5.1 813cfd38d1 test(issue-57): the audit log follows a moved registry
New file tests/test_issue57_logs_follow_registry.py: the resolver (L1),
registry.py (L2), the wrapper's submit/wait/logs (L3), publish_event.py
(L4), control_plane.from_env, worktree_prepare, status.sh, the comment-5
disk fallback, and the invariant that an explicit DELEGATE_JOB_LOGS_DIR
still wins. Every test ends by asserting the workspace's own
.mam/delegate_job_logs was never created.

Two existing tests gain monkeypatch.delenv("MAM_JOBS_DIR"): conftest's
autouse _isolate_mam_state_dirs gives every test a MAM_JOBS_DIR, and under
the sibling rule the WORKSPACE_ROOT/cwd fallbacks they assert would
otherwise resolve next to that registry. The order they assert (explicit
root, workspace, cwd) is unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-13 10:02:08 +00:00
GodopuandClaude Fable 5.1 a159a790df fix(subscriber): disk fallback reads status.json's status, not the dict
read_logged_status() returns the status.json dict; _check_disk_fallback
compared that dict with the terminal status strings, so a terminal status
recorded only in the audit log was never recognised (Issue #57, comment 5).
The dispatch-token and attempt checks are unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-13 10:00:25 +00:00
GodopuandClaude Fable 5.1 cbd87734c2 fix(status): look for the audit log next to the registry
Both get_job_status implementations (JSON and text) try the
delegate_job_logs next to MAM_JOBS_DIR and the row's mam_jobs_dir after the
explicit log roots, so a job whose registry record was cleaned up still
shows its audit-only terminal status when the registry was moved.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-13 09:57:42 +00:00
GodopuandClaude Fable 5.1 b7a857d12f fix(control-plane): derive the audit-log root from the registry in the binding and the shell defaults
from_env() fills delegate_job_logs_dir with the delegate_job_logs next to
the resolved mam_jobs_dir unless DELEGATE_JOB_LOGS_DIR is set, so a session
created with only MAM_JOBS_DIR moved no longer records the workspace's old
log root. worktree_prepare.sh passes DELEGATE_JOB_LOGS_DIR on only when the
caller set it. lib.sh and the delegate-job wrapper no longer carry a bash
copy of the default: delegate_publish_cmd, delegate_started_recorded and
_logs_dir_abs resolve it with get_logs_dir(<registry>), the wrapper fails
closed when it cannot, exports the resolved root to its children, prints
it as submit's last line, and logs takes --registry-dir.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-13 09:57:42 +00:00
GodopuandClaude Fable 5.1 b8bed8124e fix(delegation): audit-log root follows the registry (Issue #57)
get_logs_dir(registry_dir) resolves the audit-log root as the
delegate_job_logs next to the registry in use when DELEGATE_JOB_LOGS_DIR
is not set; sibling_logs_dir() is the pure sibling rule. Every writer and
reader that knows its registry (registry, engine, publish_event, remote,
job_subscriber, reconcile.sh, the loop runner's fingerprint exclusion)
passes that root instead of falling back to the workspace default.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-13 09:55:09 +00:00
GodopuandClaude Opus 5.5 e32c957464 merge: fix/u1e-workspace-root (u1e no longer writes the checkout's .mam/shim/herdr; v4.9.3)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-10-13 12:33:10 +09:00
GodopuandClaude Opus 5.5 121eab71d4 release(v4.9.3): u1e no longer writes the checkout's .mam/shim/herdr - archive the review reports and bump the lockstep version
Review loop (--review-only, --all-reviewer): round 1 (@3a30d7d) 4/4 PASS;
each reviewer reproduced W1 failing on the pre-fix tree. Seven #50/#55 and
leak modules (112 tests) from a clean worktree left no .mam in the
checkout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-10-13 12:33:02 +09:00
GodopuandClaude Opus 5.5 3a30d7da89 fix(tests): u1e gives the wrapper its own WORKSPACE_ROOT - it wrote the checkout's .mam/shim/herdr
test_u1e_a_dry_run_record_is_not_an_open_job (PR #56) ran the delegate-job
wrapper without WORKSPACE_ROOT; the wrapper sources lib.sh, which then
defaults it to the checkout and rewrites <checkout>/.mam/shim/herdr
(found by review 38a64423). The node joins W1's list, which runs its
nodes in a copy of the checkout and fails on any .mam written there.
A scan of the #50/#55 and leak modules from a clean worktree found no
other writer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-10-13 11:57:46 +09:00
GodopuandClaude Opus 5.5 42e2a7ea9b merge: docs/readme-refresh (README rewritten against the code, README.ko.md in step; v4.9.2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-10-13 11:57:34 +09:00
GodopuandClaude Opus 5.5 e3bcdee834 release(v4.9.2): README rewritten against the code, README.ko.md in step - archive the review reports and bump the lockstep version
Review loop (--review-only, --all-reviewer, fact-checked against the
code): rounds 1-3 NOT PASS with factual corrections, round 4 (@62da805)
4/4 PASS. Documentation only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-10-13 11:57:33 +09:00
GodopuandClaude Opus 5.5 62da805668 docs(readme): round-3 review fixes (21063f28)
About a third of the tests use the sandbox HOME fixtures (fewer the mock
herdr); generate-env.sh creates .mam.env only when missing, --force
overwrites with a .bak.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-10-13 11:51:32 +09:00
GodopuandClaude Opus 5.5 ae2813773c docs(readme): round-2 review fixes (00aa4a4c / 09bf3e77)
- exit 5: a busy or missing target cancels the job; a superseded attempt
  leaves the record to its new owner, unchanged
- install.sh copies a legacy .env to .mam.env and keeps the original
- restore-off fallback: without tomllib the file keeps only that key
  (restore off, other settings lost, warning); restore on only when no
  file can be written
- PoC: no token only when none is passed; tests: every test gets a temp
  registry and log dir, most also the sandbox HOME and mock herdr

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-10-13 11:32:01 +09:00
GodopuandClaude Opus 5.5 423abc7844 docs(readme): review fixes (38a64423 / c5a8813c / 7d29a620) and README.ko.md in step
- install.sh: what it actually checks; it writes a starter .mam.env with
  MQTT_BROKER=localhost (broker.hivemq.com is only the unset fallback);
  install_mam.sh is a separate installer.
- Security: tokens exist only when given or when the broker has TLS or a
  username (PoC: no signature check); the remote dispatch carries the token.
- Server isolation: create defaults to a workspace-named server; the shim
  bootstraps only a named server and never when MAM_NO_AUTOSTART=1
  (status/monitor); restore-off is the normal path, with a warning fallback.
- Loop: the selected reviewers must all pass; no reviewer option means a
  creator self-review.
- Interpreter order with the PATH python3 fallback; exit 5 also covers
  target_not_found; two layers with their own registries; test isolation
  stated as sandboxes plus after-run guards; quick start uses named sessions.
- README.ko.md now mirrors README.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-10-13 11:14:31 +09:00
GodopuandClaude Opus 5.5 20b1014717 docs(readme): bring README.md up to date with v4.9.1
Rewritten against the current code: the per-workspace shim is
.mam/shim/herdr routing with --session (not /tmp/multi-agent-herdr-shim
with -L), registry writes run in the workspace .venv (no dual-interpreter
split), and the missing pieces are added - the delegation contract (ack,
heartbeat, budget, busy wait, remote transport, exit codes), the review
loop (strict verdicts, review identity, sub-jobs, report promotion),
create --onboard exits 44/45, the per-server pane claim, restore-off
servers, worktree helpers and the test isolation rules. Layout and quick
start follow the tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-10-13 10:41:54 +09:00
GodopuandClaude Opus 5.5 2ccf43911c merge: fix/test-herdr-leak (tests no longer reach a real herdr server: a *-shim ancestor hid the mock, the sandbox kept the pane's socket; v4.9.1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-10-13 10:33:04 +09:00
GodopuandClaude Opus 5.5 50c8da8269 release(v4.9.1): tests no longer reach a real herdr server - archive the review reports and bump the lockstep version
Review loop (--review-only, --all-reviewer): round 1 (@325b056) 4/4 PASS,
round 2 (@db7f4ca, HERDR_SESSION) 4/4 PASS. Full suite at 325b056 on
macOS / Python 3.9 / bash 3.2 / herdr 0.9.3 from an agent pane:
1845 passed / 0 failed / 56 skipped (18 test_deploy_freshness skips: no
nats-docker submodule in the fresh worktree); the panes of the real
servers (default, multi-agent-mux) and ~/.claude/projects were unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-10-13 10:33:04 +09:00
GodopuandClaude Opus 5.5 db7f4cab9c test(sandbox): mam_sandbox also drops HERDR_SESSION (reviews 63919c5d / 191f0ec3, non-blocking)
The caller-server helpers read it, so tests branched on whether they ran in
an agent pane. L2 covers it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-10-13 10:01:55 +09:00
GodopuandClaude Opus 5.5 325b056057 test(issue-23): the generated-shim pin moves to the real-server leak fix (525df2b)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-10-13 09:17:57 +09:00
GodopuandClaude Opus 5.5 525df2bb58 fix(tests): tests no longer reach a real herdr server - a *-shim ancestor hid the mock, the sandbox kept the pane's socket
A suite run from an agent pane with --basetemp .../pytest-pr56-shim left
seven workspaces on that pane's real server (multi-agent-mux):
- the real-herdr resolvers (lib.sh _is_shim_path and the generated shim)
  skipped every PATH directory with "-shim" anywhere in its path, so the
  basetemp hid the mock's bin/ and the real herdr ran; only a directory
  that is itself *-shim is a wrapper now;
- mam_sandbox kept HERDR_SOCKET_PATH (and the pane/tab ids), which the real
  herdr follows over the sandbox HOME; it drops them now.

tests/test_herdr_real_server_leak.py: L1 (ancestor *-shim, both resolvers)
and L2 (pane routing dropped) fail before the fix; L1b keeps a *-shim
wrapper directory excluded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-10-13 09:15:22 +09:00
Godopu 8b0b4a07dc merge: fix/issue-50-55 (Issue #50 / #55: onboarding and dispatch wait by the registry, one pane claim per server, tests stay out of the real agent homes; v4.9.0)
Closes #50
Closes #55
2026-10-12 12:38:55 +00:00
GodopuandClaude Opus 5.5 ffe8665b27 release(v4.9.0): Issue #50 / #55 - archive the review reports and bump the lockstep version
Merge review on macOS (--review-only loops, --all-reviewer): round 1 at
320aa9f 4/4 NOT PASS, round 2 codex F1 (a claimed dry-run job holds its
session), round 3 4/4 PASS (discarded: the worktree changed mid-review),
round 4 at 21f0c12 claude/agy/grok PASS with codex interrupted by a stream
disconnect, round 5 codex PASS. Full suite at 21f0c12 on macOS / Python
3.9 / bash 3.2 / herdr 0.9.3: 1860 passed / 0 failed / 38 skipped; no new
entries in ~/.claude/projects.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-10-12 21:38:50 +09:00
GodopuandClaude Opus 5.5 21f0c12b9e fix(issue-50): the monitor labels a row with the server that answered
A `default` scan runs without -L, so the shim answers it from the inherited
HERDR_SESSION_NAME; a create's reconcile --once labelled that server's panes
`default`, and since create adopts only a row of its own server (1e25cf2f
F2), parallel creates in one folder were refused (test_tier4_e2e scenario 4).
The skill-blocks data follows create_session.sh's SystemExit line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-10-12 10:37:50 +09:00
GodopuandClaude Opus 5.5 efd27def47 fix(issue-50): a claimed dry-run job holds its session (review 211037ab F1)
open_jobs skips a dry-run record only while it is pending: a pick-pending
claim makes it running work, which the admission must wait for.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-10-12 10:12:05 +09:00
GodopuandClaude Opus 5.5 a9ffedac36 fix(issue-50): review findings of PR #56 on macOS
- admission: a `submit --dry-run` record is marked dry_run and is not an
  open job of its session (review 1e25cf2f F1)
- create: the monitor-row adoption requires the row's server to be the one
  this create spawned on (review 1e25cf2f F2)
- pane lock: retry only on contention (BlockingIOError); the docs say the
  flock lasts while any fd-9 holder (the claim shell and the shim) lives
  (review 66680d9d)
- tests on macOS: real-herdr box HOMEs under /tmp (sun_path is 104 bytes),
  resolved paths, the fake claude reads non-canonical input (MAX_CANON 1024)
  and the pane is read unwrapped; lock holders exec their sleep so killing
  them frees the lock; the adoption tests name the server

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-10-12 09:03:08 +09:00
GodopuandClaude Fable 5.1 320aa9f7a0 test(issue-50): c10 makes the same-second tie instead of timing it (reviews 98b31bb2 / 352066ec / 244b8072 F-B)
The live onboarding test asserted that the first job's wrapper registered in
the same wall-clock second as the onboarding it had just placed at a second
boundary. Process start-up has no sub-second contract, so the setup assertion
failed now and then before any dispatch behaviour was checked. The test now
stamps the onboarding record, which it owns and which nothing else depends on
yet, with the job's created_at once that record exists; the stamp is written
by atomic replace so a scanning wrapper never skips a half-written record.
The lifecycle assertions and the fixed-id tie coverage in test_u2h are unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-11 12:05:19 +00:00
GodopuandClaude Fable 5.1 6c99fedf40 fix(issue-50): an injected record holds its session whatever its FIFO place; the ambient pane claim locks the caller's server (reviews 5a09a13f F-A, e8e471ff R1)
open_jobs reports a live pending record nobody signals on as `injected`:
its producer registered and injected it without a waiter (create
--onboard, a job sent by hand), so it holds the session like a running or
admitted job, and a waiter of the same second with a lower job id, or one
registered a moment earlier and still in its preflight, no longer passes
it in the FIFO. The waiter signals on its own record before its first
scan, so its record is never mistaken for one.

lib_py.pane_lock resolves the shim's ambient target (no -L,
HERDR_SESSION_NAME empty or `default`) to the server of the pane the
caller runs in - HERDR_SESSION, else HERDR_SOCKET_PATH's layout, as
lib.sh _herdr_caller_server reads them - so an ambient and a named call
into one server claim under one lock; outside a pane it stays the
default server's.

Tests: u2h (the tie, the later-registered onboarding, the signalled
control), u1/u4 labels, c10 forces the same-second registration, p1b,
s2 (real herdr: held lock stops both call forms; mixed concurrent calls
take distinct panes). Two FIFO fixtures now carry a waiter's signal.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-11 08:50:46 +00:00
GodopuandClaude Fable 5.1 d50610c1ab fix(issue-50): a waiter whose own attempt is already running answers clear, not superseded
The registry wait judged its record by one rule: not this attempt's
pending record in this session -> superseded (redispatched, moved or
closed by someone else). That also caught the record's own `started`
landing before the prompt went out - the Issue #14 registry ack winning
over an unreachable broker, as test_issue30_stall_watch_stop acknowledges
at registration - and the wrapper left on exit 5 instead of its
broker_unavailable path (rc 3).

A record that is running under this attempt's token in this session is
this attempt's own progress: the waiter answers `clear` at once, writes
no admission and starts no hold. Another token, another session or a
terminal status stay `superseded` (review 55219be1 R1 unchanged).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-11 06:28:01 +00:00
GodopuandClaude Fable 5.1 7844ae2c5f fix(create): one pane claim at a time per herdr server; a create keeps the pane the monitor registered for it
Three `create --onboard` at once into one folder (the control root, so no
control-plane prefix reaches the pane and the shim reuses an idle pane):
the shim's new-session chooses a pane - an idle one in a workspace on the
same cwd, a split, a fresh workspace's root pane - and then claims it with
`agent start`; two shims could choose the pane one of them had just made,
and the later start failed with agent_pane_busy (one create in three).

mam_herdr now runs every new-session call holding one lock per server: a
flock on fd 9, opened for the block and taken by lib_py.pane_lock, which
also bounds the wait (MAM_PANE_CLAIM_WAIT_S, default 120). It is released
with the block or with the process, however it ends. The shim heredoc is
unchanged (S11). Other servers' creates are not held.

Serialising the claims exposed a second race: a create's end-of-run
`reconcile.sh --once` adopted the pane of another create that had not yet
written its row, and that create refused its own name as already running
(exit 4). Such a row - registered by the monitor, no job, for a pane
created after this create began - is now replaced, not refused.

Tests: real herdr - shim-level and create-level three-way races into one
folder (the real herdr's pane-creating replies held back so the window is
hit on every run; both fail on the base with the orchestrator's error),
the different-folder test kept; pane_lock unit tests (per-server file,
timeout, kill -9 holder, handover, knob); the adoption rule and its three
refusals; shim contract h26 (held lock: no pane calls). The herdr box gets
a ~/.zshrc: without it every first `agent start` typed into zsh's first-run
menu and timed out at 30 s.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-11 06:05:52 +00:00
GodopuandClaude Fable 5.1 30c25f193c fix(issue-50): the waiter and its hold are pinned to one attempt and session; an unsignalled pending record lives by its ack budget (review 55219be1 R1, R2)
R1: wait-open-jobs takes --dispatch-token (the token the wrapper read for
this attempt) and keeps it, with the session it asked about, for its whole
life; every write (signal, admission, the hold's renewal) is a guarded
engine merge that re-reads the record under the registry lock and refuses
once the record is not that attempt's pending one in that session. A record
redispatched, moved or closed by someone else makes the waiter answer
`superseded`: the bash wrapper exits 5 without a close, the record is its
new owner's. hold-admission is given the waiter's pinned identity instead
of reading a token from disk, so a hold that starts late holds nothing.

R2: a pending record that never carried a waiter's signal (create --onboard
registers and injects directly and waits ack_timeout_sec for `started`) is
live for that budget from created_at, not for the 30 s signal window; the
window applies only once a dispatcher has signalled.

Tests: u1 rows for the two pending lifetimes, u3b asserts the guard, u3f/u3g/u3h
(cross-session redispatch, in-place attempt change, token changed before the
waiter started) with the real waiter subprocess, u4e/u4f (late hold, running
hold released by a redispatch), s1 pins the token argument and the no-close
branch; real herdr C10 (onboarding valid before and after 30 s blocks the
session), C11 (expired onboarding is stale), C12 (redispatched waiter leaves
its record alone, exit 5).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-11 02:58:16 +00:00
GodopuandClaude Fable 5.1 9cbe08e8de fix(issue-50): the registry wait admits one job per session and holds it (review aa1bff92 F1, F2)
F1: `clear` is now an admission persisted on the released record - a merge
through the engine whose new `guard` re-reads the session's open jobs under
the registry lock, so two waiters cannot both be admitted. It is scoped to
the attempt by the dispatch token (a redispatch voids it) and reported as
`admitted` to every other waiter, which holds the session as a running job
does, whatever its FIFO place: a lower id in the same second or a
redispatched older loop job now waits.
F2: a pending record's liveness window is the dispatcher's signal cadence
times three misses (10 s x 3 = 30 s), no longer ack_timeout_sec, which the
agent's budget takes down to 1 s. After `clear` a detached hold renews the
admitted record's signal until the record leaves pending, its attempt
changes, the wrapper is gone, or the wall clock ends.
Tests: u1 boundaries, u1d/u2e/u2f/u2g/u3d/u3e for the admission, u4c/u4d for
the hold, s1 pins --hold; real herdr: C3/C7/C8 adjusted, C9 is C8's
live-owner counterpart with short ack windows.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-11 01:29:57 +00:00
GodopuandClaude Fable 5.1 9e78ff3ba3 fix(issue-50): the loop path reads this attempt's token before it waits (review reviewer-grok-01)
The `--status pending` reset is a redispatch and issues a new dispatch
token, but the loop read `_disp_tok` only after the registry and idle waits.
A busy or idle_wait close from phase two on therefore carried the previous
phase's token and the engine refused it, leaving the record pending while
the caller saw session_busy. The read now follows the reset directly; s1
pins reset < token read < registry wait.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-10 18:14:48 +00:00
GodopuandClaude Fable 5.1 3705bc72a8 fix(issue-50): the loop path waits by the registry on its pending record (review F1-F3)
F1: from phase two on, the loop's `_registry_wait` ran before the record was
reset to pending, so the waiter held a terminal record: the engine refused its
dispatch_wait_at merge and other waiters never saw it in the FIFO. The wait
(and the idle wait after it) now follows the `--status pending` update; s1
pins the order and u3c shows why a terminal self record cannot signal.
F2: the real-herdr box teardown survives a hung stop/delete, kills a server
bootstrapped by a late client call, and asserts the server and HOME are gone.
F3: the delegate-job SKILL.md exit 5 row names the registry wait.
Also drops the dead loop in s1.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-10 17:28:09 +00:00
GodopuandClaude Fable 5.1 05bffc6475 test(issue-50): C8 registers its two jobs in different seconds
The FIFO is (created_at, job_id) at second resolution; B and C registered
within the same second once in the full suite, C sorted first by id and
cleared while B's signal was still fresh, so the stale line never printed.
C7 already keeps the 1.2 s gap; C8 now does too and asserts the order.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-10 17:14:14 +00:00
GodopuandClaude Fable 5.1 630f04bfdc test(issue-50): the two new knobs join the #26 table; the herdr box sweeps a late state write
MAM_ONBOARD_WAIT_S (min 1, 600) and MAM_DISPATCH_WAIT_S (min 0, 600) in
test_issue26_ints.INT_KNOBS, which pins every mam_int_knob call. The
real-herdr box's teardown removes its HOME once more after a second:
herdr writes .local/state/herdr/agent-detection a moment after the
server is gone, which left an empty /tmp/mam50-* behind.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-10 16:05:59 +00:00
GodopuandClaude Fable 5.1 e9efabf74b feat(issue-50): the delegate-job wrapper waits by the registry before it injects (C)
Reproduction first (herdr 0.9.3, a real claude, one-off server, job
51ccd800): after a job that left a background subagent running
(`← 1 agent` on screen for 5 min), herdr reported `working` only while
the job ran and `idle` ~10 s after `completed`; a second submit with a
20 s idle wait was injected, not cancelled. The 2026-10-04 observation
(0.9.1, 10+ min of `working`) does not reproduce on 0.9.3, so the herdr
idle wait stays and the registry wait runs before it (plan §4.3 (나)).

- lib_py/delegation/wrapper.py: `open-jobs` (diagnostic) and
  `wait-open-jobs`. An open job of the session is live when the #18
  contract says so: `running` with an event in events.log within
  idle_timeout_sec; `pending` with a dispatch signal (dispatch_wait_at,
  else created_at) within ack_timeout_sec. A dead record is reported
  (`stale open job <id> ignored (...)`) and never waited for. The waiter
  signals every 10 s by merging dispatch_wait_at into its own pending
  record through the registry's transition boundary (status untouched, no
  event), so later waiters see it alive; a waiter whose wrapper is gone
  stops and its signal goes stale (Creator's challenge, job bf8863e1).
  Release is FIFO by (created_at, job_id): `clear` when no live running
  job remains and this job is the earliest live pending one; `busy <ids>`
  at the bound min(MAM_DISPATCH_WAIT_S, remaining wall clock).
- the wrapper: _registry_wait after the preflight in both dispatch paths;
  `busy` closes the job as cancelled/session_busy (phase registry_wait),
  exit 5 - the existing contract.
- tests/test_issue50_dispatch.py: the open-job table (status x age x
  signal x spelling), FIFO and the tie, the signal as a registry merge,
  the bound, a dead wrapper, finalize-end's new phase, both call sites.
- tests/test_issue50_dispatch_herdr.py: a real herdr 0.9.3 server per
  test (short mkdtemp HOME, fake claude the server reports idle, the real
  publish path over the suite's broker): C1 wait-then-run, C2 the bound,
  C3 a dead running record, C4 the loop runner's first job behind an
  onboarding, C5 a session without a row, C7 two waiters in FIFO order
  with no cancellation, C8 a killed waiter skipped once stale; and three
  concurrent `create --onboard` / `worktree_prepare.sh --onboard` in one
  server (one server process survives the shims' concurrent bootstrap,
  so no bootstrap lock is needed).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-10 15:39:21 +00:00
GodopuandClaude Fable 5.1 c29c5b2142 feat(issue-50): create --onboard waits for the onboarding job to end (A)
The handshake only said the agent saw the prompt (#23). create_session.sh
now waits, after `started`, for the onboarding job to reach a terminal
status and makes that its own result, so the orchestrator learns it from
create's exit (MULTI_AGENT_RULES §6) instead of guessing from herdr's
agent_status:

- lib_py/delegation/wrapper.py: `wait-terminal` (completed|error|cancelled,
  `timeout` after --timeout, `missing` when the record is gone; stops with
  its caller via --watch-pid) and `onboard-exit` (completed 0, error or
  cancelled 44, timeout or missing 45). bash branches on the printed answer.
- lib.sh: delegate_wait_terminal / delegate_onboard_exit, thin calls.
- create_session.sh: `onboarding completed: job <id> (<n>s)` on stdout, or
  `last_visible_status: onboarding-<status>` on the row, the reason on stderr
  and `exit 44` / `exit 45` after `trap - EXIT` - the session is kept, as for
  a missing handshake. The "no started" path (error, exit 0 before) now ends
  with 44. MAM_ONBOARD_WAIT_S (default 600, floor 1) bounds the wait.
- worktree_prepare.sh: 44/45 pass through with "session and worktree kept".
- create SKILL.md exit table (44, 45) and the knob; the #42 inventory follows.
- tests/conftest.py: --onboard is the default, so ~60 sandbox create tests
  reach the new wait. The mock herdr now acts as the agent for create's
  onboarding prompt - it runs the publish commands the prompt names through
  the real publish_event.py - and MAM_ONBOARD_WAIT_S=3 caps a test that turns
  that off (MAM_MOCK_ONBOARD_HANDSHAKE=0 -> 44, `started` -> 45). The
  delegate-job wrapper's prompt has another shape and is never answered.
- tests/test_issue50_onboard.py: the decision tables, create in the sandbox
  (0 / 44 / 45, session kept, row note, trap window), the knob's integer
  rule, worktree_prepare's pass-through.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-10 15:39:21 +00:00
GodopuandClaude Fable 5.1 40293841e7 test(issue-55): tests no longer write into the user's real agent homes
An agent pane inherits HOME_DIR, CLAUDE_PROJECT_DIR and LOCAL_BIN from
lib.sh's exports (and the adapters read CODEX_HOME). DiscoveryContext
prefers CLAUDE_PROJECT_DIR over the home_dir a test passes and every
adapter path reads HOME_DIR before HOME, so a suite run by an agent
wrote fake conversation folders into the user's real ~/.claude/projects
(test_adapter_discover, 40 folders on 2026-10-10).

- conftest._isolate_agent_homes (autouse): drops the four pane variables
  for every test, so HOME - which the suite's tests set for an isolated
  home - governs, as in a plain shell. Dropped rather than pointed into
  tmp_path: a fixture-set HOME_DIR would shadow the 11 adapter-contract
  tests that set HOME alone, the same way the pane's value did.
- conftest.agent_home_leak_guard (session autouse) + tests/_agent_home_guard.py:
  snapshots the entry names of the real claude/agy/codex/grok/opencode/
  hermes conversation directories (as the ambient environment names them,
  recorded in pytest_configure) and fails on new ones; read-only. A claude
  folder whose name decodes (against the file system) to a real directory
  outside the temp roots is an operating agent's and is not counted.
- tests/test_issue55_agent_homes.py: the dropped-variable list is pinned
  against the env reads of lib_py/agents, lib_py/paths.py and lib.sh;
  guard unit tests; the issue's reproduction as a subprocess regression
  (on base 42e5173 it leaves <dir>/-…-test-adapter-discover0-ws/u-c1.jsonl).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-10 14:29:36 +00:00
Godopu 42e51735ba merge: test/loop-marker-tmp (tests no longer take the checkout's loop lock; v4.8.9)
4/4 PASS in the merge workspace; see VERSIONS.md v4.8.9.
2026-10-09 01:59:58 +00:00
GodopuandClaude Opus 5.5 ca688dcef4 release(v4.8.9): tests no longer take the checkout's loop lock - archive the review reports and bump the lockstep version
Merge-workspace review loop (--all-reviewer): round 1 (@a93c9bf) 4/4
PASS. Full suite at a93c9bf on macOS / Python 3.9 / herdr 0.9.3 with
WORKSPACE_ROOT set to an empty sentinel: 1752 passed / 0 failed / 38
skipped; the sentinel stayed empty, the review worktree had no .mam, and
the main checkout's shim was not touched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-10-09 10:59:38 +09:00
GodopuandClaude Opus 5.5 a93c9bf292 test(loop-lock): the b13 runs of the checkout's run_loop.sh take a loop lock of their own
test_b13_reexec_preserves_original_argv and test_b13_no_freeze_switch_
disables_reexec ran the checkout's run_loop.sh with --creator, which gets
past argument parsing to mam_acquire_loop_lock on the default marker,
$MAM_REAL_ROOT/.mam/loop-guard-active with MAM_REAL_ROOT = the script's own
checkout. In the main checkout that is the live loop's lock (taken and
released for a moment: a real loop refused, or the test refused); in a review
tree it left an empty .mam. Both now set MAM_LOOP_MARKER under their tmp_path.

A sweep of the 16 modules that use run_loop.sh (a per-test watcher for a new
.mam in the checkout, an empty .mam removed before each test) found only
these two; the others pin MAM_LOOP_MARKER already, end before the lock
(--help, argument errors), or run a copy of the tree.

Tests: test_loop_marker_isolation.py L1 runs every test that reaches the lock
through the checkout's run_loop.sh in a copy of the checkout and requires no
.mam there; W1 of the Issue #17 isolation test now also requires that no .mam
exists (it only looked for files, so the empty lock directory passed it). At
97c2946 both fail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGpJXhSZasJLU38ksDghe
2026-10-09 09:01:34 +09:00
Godopu 97c2946ee0 merge: test/issue17-workspace-root (Issue #17 follow-up: tests that source lib.sh no longer write into an inherited WORKSPACE_ROOT; v4.8.8)
Refs #17 (#17 stays open). 4/4 PASS in the merge workspace; see VERSIONS.md v4.8.8.
2026-10-08 20:01:25 +00:00
GodopuandClaude Opus 5.5 e0181ea9be release(v4.8.8): Issue #17 follow-up - archive the review reports and bump the lockstep version
Merge-workspace review loop (--all-reviewer): round 1 (@d198dd5) claude
agy PASS, grok codex NOT PASS (the same inherited WORKSPACE_ROOT in eleven
more modules; test_orc_onboard's import-time env); fixed in 8b47cc7.
Round 2 claude grok NOT PASS (test_o2_race_free_lock, test_o1_rebuttal
missed), agy codex PASS; fixed in ac48cb7. Round 3 (@ac48cb7) 4/4 PASS.
Full suite at ac48cb7 on macOS / Python 3.9 / herdr 0.9.3 with
WORKSPACE_ROOT set to an empty sentinel: 1751 passed / 0 failed / 38
skipped; the sentinel stayed empty and the main checkout's shim was not
touched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-10-09 05:01:05 +09:00
GodopuandClaude Opus 5.5 ac48cb7861 test(issue-17): test_o1_rebuttal and test_o2_race_free_lock run run_loop.sh under their own WORKSPACE_ROOT (review claude, grok)
Two modules still ran the checkout's run_loop.sh - which sources lib.sh
before it parses an option - with the process environment and no root of
their own: test_o2_race_free_lock's test_o2_12 (review claude; it was in the
sweep's list and missed in 8b47cc7) and test_o1_rebuttal's
test_v3_rebut_option_parsing_and_budget, whose calls pass no env (review
grok; outside the env-copy grep). Both now use own_workspace_root, and W1
runs one node of each: at 8b47cc7 W1 fails with the copy's .mam/shim/herdr.

A last pass over the test files that run lib.sh, run_loop.sh, the wrapper or a
skill script: every one is marked, uses mam_sandbox or pins WORKSPACE_ROOT,
or does not source lib.sh (b7 diff_collect, deploy, issue42, issue45 config,
issue5_phase0, local_mqtt).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGpJXhSZasJLU38ksDghe
2026-10-09 02:24:22 +09:00
GodopuandClaude Opus 5.5 8b47cc76ee test(issue-17): every test that sources lib.sh does it under its own WORKSPACE_ROOT (review grok, codex)
d198dd5 fixed one helper; the same defect was in eleven more modules
(reviews grok and codex R1/R2): their shells inherited the process
environment, so each `source lib.sh` rewrote <WORKSPACE_ROOT>/.mam/shim/herdr
- the checkout's own when the variable is unset or names this checkout (which
pytest_configure keeps), another workspace's under --noconftest.

- conftest: own_workspace_root, an opt-in fixture setting WORKSPACE_ROOT to
  <tmp_path>/.mam-test-state/workspace. Not autouse: runner.py and
  mqtt_common read WORKSPACE_ROOT in-process, and some tests unset it on
  purpose. mam_sandbox and explicit envs run later and still win.
- pytestmark usefixtures("own_workspace_root") in test_version_consistency,
  test_b19_headless_reconcile_fixes, test_b8_send_keys_verification,
  test_issue6_fail_closed, test_loop_cli, test_issue5_phase3_loop,
  test_issue5_phase4_runtime, test_o3_scoped_guard, test_issue25_plan_reuse,
  test_orc_onboard (their shells inherit os.environ, many with no env at all).
- test_orc_onboard (R2): SCRUBBED_ENV is taken at import and no longer
  carries WORKSPACE_ROOT; every call builds _scrubbed_env(root) with the root
  of the call (ws / cwd / mam_sandbox, else this test's).
- test_workspace_scope (unittest) and test_issue21_worktree_prereqs._mutate
  pin the root they already create.

Tests (test_issue17_workspace_root_isolation.py): W1 copies the checkout and
runs one test of each of those modules there with WORKSPACE_ROOT naming the
copy - the copy gets no .mam; W2 imports test_orc_onboard under a sentinel
root, then calls it under another - the sentinel stays empty. At d198dd5 both
fail (the copy gets .mam/shim/herdr; the sentinel gets it).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGpJXhSZasJLU38ksDghe
2026-10-09 00:22:54 +09:00
GodopuandClaude Opus 5.5 d198dd50aa test(issue-17): item-4 helpers run lib.sh in the test's own WORKSPACE_ROOT
_env kept an inherited WORKSPACE_ROOT, so every helper that sources the
wrapper (lib.sh's _init_herdr_isolation) rewrote <that workspace>/.mam/shim/
herdr - another workspace's during the PR #51 review (v4.8.7 review, claude).
Only dropping it is not enough: lib.sh then derives the checkout itself, and
conftest keeps a WORKSPACE_ROOT that names this checkout, so the run would
rewrite the checkout's own shim (the real one when this is the main
checkout). _env now sets WORKSPACE_ROOT to the test's tmp dir.

Test: with a sentinel WORKSPACE_ROOT in the environment, _run_both and a
direct _env run leave the sentinel empty and the checkout's shim untouched,
and the shell sees the tmp dir. With the old _env the sentinel gets
.mam/shim/herdr.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGpJXhSZasJLU38ksDghe
2026-10-08 23:10:35 +09:00
Godopu 6b00b6e06f merge: fix/issue17-followups (Issue #17 follow-ups: a failed decision ends the wrapper with 1; resume closes herdr 0.9.3's placeholder; v4.8.7)
Refs #17 (#17 stays open). 4/4 PASS in the merge workspace; see VERSIONS.md v4.8.7.
2026-10-08 02:32:57 +00:00
GodopuandClaude Opus 5.5 cd82ab6e72 release(v4.8.7): Issue #17 follow-ups - archive the review reports and bump the lockstep version
Merge-workspace review loop (--all-reviewer): round 1 (@8241bd5) claude
grok PASS, agy NOT PASS (two static wrapper-literal tests did not follow
_decided/_exit_for), codex NOT PASS (R1: a yes/no decision that could not
run read as "no"); fixed in 541c3fe and 65b78b6. Round 2 (@65b78b6) 4/4
PASS. Full suite at 65b78b6 on macOS / Python 3.9 / herdr 0.9.3: 1730
passed / 0 failed / 56 skipped; 18 of the skips were the uninitialized
nats-docker submodule in the fresh worktree, and with it
tests/test_deploy_freshness.py is 31 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-10-08 11:32:39 +09:00
GodopuandClaude Opus 5.5 65b78b6ec4 fix(issue-17): a failed yes/no decision is not a "no" (review codex R1)
subscriber-up and stall-watch answered only by exit code, so a call that
could not run (module error rc 1, missing interpreter 127) read as "no":
with a subscriber that had ended 0, the error path mapped 0 to completed
and the submit ended with 0; a failed stall-watch silently skipped the
watcher.

Both now take --print: the answer is printed as yes/no and the exit code
is 0 for either. The wrapper asks with --print through _decided_yes, so a
nonzero exit, no answer or anything else is _decision_failed (ERROR line,
exit 1). Without --print the rc contract is unchanged (yes 0, no 1).

Tests: subscriber-up (direct and loop/discuss call sites) and stall-watch,
each failing by traceback, rc 127 or no answer with a subscriber that
ended 0, end the submit with 1 and the ERROR line - at 8241bd5 eight of the
nine ended with 0 (the loop/no-answer one ended with 1 through the loop's
own result and failed only on the ERROR line); yes and no keep their
branches in both paths; --print unit table; the static guard forbids an
rc-read yes/no decision.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGpJXhSZasJLU38ksDghe
2026-10-08 09:26:41 +09:00
GodopuandClaude Opus 5.5 541c3febe8 test(issue-17): the frozen wrapper literals follow _decided/_exit_for (review agy)
a670ef5 routed the wrapper's sub-status and exit-code decisions through
_decided / _exit_for, but test_tier1_unit G-10 and test_issue5's
test_wrapper_exit_code_mapping_literals_are_frozen still looked for the raw
`_decide sub-status "$sub_rc"` and `_decide exit-code "$job_status"`. They now
pin `_decided job_status sub-status "$sub_rc"` and `_exit_for "$job_status"`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGpJXhSZasJLU38ksDghe
2026-10-08 09:26:41 +09:00
GodopuandClaude Opus 5.5 8241bd53b1 test(issue-17): the failing wait-rc test sources the wrapper in its own WORKSPACE_ROOT
Sourcing the wrapper sources lib.sh, which writes $WORKSPACE_ROOT/.mam/shim/
herdr; without WORKSPACE_ROOT that is the checkout. The new N2 wait-rc test
now runs with its tmp dir as WORKSPACE_ROOT, so the N2 tests leave the
checkout untouched. (The existing C tests in this file still write the
checkout's .mam/shim/herdr, as at 56d0bfd - not changed here.)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGpJXhSZasJLU38ksDghe
2026-10-08 08:14:05 +09:00
GodopuandClaude Opus 5.5 c90a0afa96 docs(issue-17): lib_int.sh runs python3 from PATH (N3)
mam_int / mam_int_knob call `python3 -I` because the file is also sourced
without lib.sh and so without $PY. The header now says so, and that without
a python3 on PATH they print nothing (rc 127). The code is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGpJXhSZasJLU38ksDghe
2026-10-08 08:12:44 +09:00
GodopuandClaude Opus 5.5 d6cb01987c docs(issue-17): wrapper.py imports only the stdlib, the package it runs in loads paho (N1)
"Stdlib only" is true of the module, not of how the wrapper runs it:
`python -m lib_py.delegation.wrapper` imports lib_py/delegation/__init__.py
first, which loads the service and paho-mqtt. The docstring now says so; the
wrapper's $PY already needs paho. The run path is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGpJXhSZasJLU38ksDghe
2026-10-08 08:12:44 +09:00
GodopuandClaude Opus 5.5 a670ef53ee fix(issue-17): a decision the wrapper cannot make ends it with 1 (N2)
`exit "$(_decide exit-code ...)"` became `exit ""` when wrapper.py could
not answer (its module or $PY broken): rc 255 on bash 3.2. The other printed
decisions failed open in their own ways: a failed wait-rc returned "" (255),
a failed finalize-begin left finalize-end with an empty --final (argparse
rc 2), and a failed sub-status or finalize-end ended under set -e with the
interpreter's own code (1 for a traceback, 127 for one that cannot run).

Printed decisions now go through _decided <var> <subcommand>, and the
exits through _exit_for <status>: a failed call, an empty answer or a
non-numeric exit code / wait rc prints one ERROR line and exits 1. finalize-
end may legitimately answer nothing (its wrapper gone), so only its rc is
checked. The normal path is unchanged: same answers, exit codes and output.

Left as they are: subscriber-up and stall-watch are answered by rc, so a
failure reads as "no" - the subscriber is then treated as dead (the error
path, whose sub-status/exit-code are now fail-closed) and the notify-only
stall watcher is not started.

Tests (F): the real submit (human job, unreachable broker, 3 s budget) with
a $PY that fails one decision - exit-code, sub-status, finalize-begin,
finalize-end; traceback, rc 127 or no answer - ends with 1 and the ERROR
line; the same run without a failure still ends with 3. cmd_wait with a
failing wait-rc ends with 1. A static guard keeps printed decisions off
raw $(...). At 56d0bfd: exit-code 255/255, finalize-begin 2/2, wait-rc
255/255, sub-status and finalize-end 127 for "gone"; the traceback and
no-answer cases there already ended with 1 (by set -e or by the empty
status mapping to 1) and fail only on the missing ERROR line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGpJXhSZasJLU38ksDghe
2026-10-08 08:12:16 +09:00
GodopuandClaude Opus 5.5 f19f94099f fix(issue-17): resume closes herdr 0.9.3's placeholder for a removed directory
With the cwd premise fixed, test_b still failed on herdr 0.9.3: a pane whose
saved directory is gone is restored as a placeholder - `restore_error`
"Saved directory is unavailable...", no process - and `pane process-info`
answers pane_not_found. close_restored_shells raised on it and exited 1
before looking at the other panes, so the resumed session's removed-worktree
pane (the case #45 closes on herdr 0.9.1, where it is a shell in $HOME) was
left behind.

Such a pane has nothing running, so it now meets condition 4. Only that
exact answer counts - restore_error set AND pane_not_found; any other
process-info failure still stops the run with exit 1 as before.

Tests: test_issue45_restore.py passes on herdr 0.9.3 (5/5). Unit tests with a
stub herdr: the placeholder is closed (fails at 56d0bfd); pane_not_found
without restore_error, or restore_error with another error, still raises.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGpJXhSZasJLU38ksDghe
2026-10-08 07:53:58 +09:00
GodopuandClaude Opus 5.5 25c99704f2 test(issue-17): herdr 0.9.3 keeps a removed directory as the restored pane's cwd
test_b_only_the_resumed_sessions_restored_shell_is_closed asserted that a
pane whose directory was removed comes back in $HOME. That is herdr 0.9.1;
herdr 0.9.3 keeps the old path. The cwd check now takes either, with a note
on which version does what; closing never looked at the cwd.
herdr_restore.py's docstring said the same "$HOME when that directory is
gone" and now names both versions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGpJXhSZasJLU38ksDghe
2026-10-08 07:52:40 +09:00
Godopu 56d0bfda54 merge: refactor/issue-17-item4 (Issue #17 item 4: the delegate-job wrapper's decisions move to Python; v4.8.6)
Refs #17 (item 4 only; #17 stays open). 4 reviewers in the merge workspace; see VERSIONS.md v4.8.6.
2026-10-07 16:37:53 +00:00
GodopuandClaude Opus 5.5 8fde39be33 release(v4.8.6): Issue #17 item 4 - archive PR #51 reviews and bump the lockstep version
Merge-workspace review: round 1 (@518beda) claude agy grok PASS, codex
NOT PASS (finalize wait measured on the wall clock); fixed in 4c86107.
Round 2 delta (@4c86107) claude agy grok PASS; codex (24b21b9c, after
817d1650 closed deadline_timeout with no report) found R1 closed and no
new defect, but withheld PASS only because its sandbox could not bind
the suite's loopback broker. The same two-file suite run here at
4c86107: 278 passed. Full suite at 518beda on macOS / Python 3.9:
1711 passed / 1 failed / 38 skipped; the failure
(test_issue45_restore, herdr 0.9.3 cwd restore) also fails on main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-10-08 01:37:48 +09:00
GodopuandClaude Opus 5.5 4c86107846 fix(issue-17): review R1 - a finalize wait is measured on the monotonic clock, like bash's sleep
codex, PR #51 round 1. wrapper._sleep computed the span and the remainder
with time.time(). The broker re-observation (up to 3 s) and the disk-heartbeat
grace (1 s) use it, so a wall clock stepped back during the wait stretched it
by the step: on a mocked clock stepped back 10 s, an already completed job was
seen after 11 s / 13 s instead of 1 s / 3 s. The old bash ran `sleep 1` /
`sleep "$left"`, which do not follow the wall clock.

_sleep now measures on time.monotonic(). The deadline (START_TIME + TIMEOUT),
the evidence window and event timestamps stay on the wall clock (_now), as in
the bash.

test_u_a_wait_is_not_stretched_by_a_wall_clock_step_back (1 s, 3 s) swaps the
module's time for a clock stepped back 10 s during the first sleep; on the
previous code it measures 11.0 / 13.0 and fails. The comparison test and the
finalize-related behaviour tests (issue33, issue18, issue14) on macOS /
Python 3.9: 321 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-10-08 00:53:09 +09:00
GodopuandClaude Opus 5.5 518bedac5d fix(issue-17): review R1 round 2 - an exited, unreaped wrapper is not a live owner
kill(pid, 0) succeeds for a zombie. A caller that captures the wrapper's
output and calls communicate() after TERM leaves the wrapper unreaped while
the finalizer holds its pipes, so the owner check passed until the deadline
and the job was still closed (codex, round 2; reproduced with the real
registry). _alive now asks `ps -o stat=` (POSIX, no /proc) once kill(0)
succeeds and treats state Z as gone, as tests/conftest.py::_pid_alive does;
no ps answer keeps kill(0)'s verdict. The owner is looked at every 0.5 s.

Tests: TERM + communicate() with no wait() for both graces (stand-ins) and
for the timeout grace with the real registry, old vs new; a zombie is not
alive. All four fail on c5cebe6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 08:09:13 +00:00
GodopuandClaude Opus 5.5 c5cebe6c32 fix(issue-17): review R1 - the finalizer ends with its wrapper
finalize-end ran the disk grace and the broker re-observation as a child
of the wrapper; TERM to the wrapper left it running, and at the deadline it
still closed the job and published the error (codex R1, reproduced with the
real registry). The bash loops it replaced ended with the wrapper.

The wrapper now passes its own pid (--watch-pid "$$", as for the stall
watcher). finalize-end checks it every 0.2 s through both waits and before
the close and the event, and leaves without either once it is gone.
A comparison test sends TERM to the old and the new wrapper inside each
grace: both exit at once, leave no process behind and make no call.

Also: no blank line at the end of the old-decisions fixture, and the #42
inventory follows main() one line down.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 07:02:26 +00:00
GodopuandClaude Opus 5.5 66d1490230 test(issue-17): item 4 - l15's stand-in control root carries lib_py/ints.py next to lib_int.sh
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 05:37:02 +00:00
GodopuandClaude Opus 5.5 6947d3c785 test(issue-17): item 4 - the #42 inventory follows runner.py's shorter header
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 05:36:48 +00:00
GodopuandClaude Opus 5.5 3b3ce307df test(issue-17): item 4 - the #42 inventory points at main() where it now is
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 05:36:23 +00:00
GodopuandClaude Opus 5.5 6bf9f92462 test(issue-17): item 4 - the frozen-mapping test imports the wrapper module directly (it has no scripts/ shim)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 05:08:35 +00:00
GodopuandClaude Opus 5.5 63d9c205ff refactor(issue-17): item 4 - the delegate-job wrapper's decisions move to Python
The wrapper keeps spawn / wait / kill, key injection and the herdr
diagnostic probe; what it decided in bash is now lib_py/delegation/wrapper.py,
reached through one `_decide` helper:

- finalize_unfinished: the clock choice, the disk grace and broker
  re-observation, the close request / label, the close and the event
  (finalize-begin / finalize-end; bash runs only the busy probe between them)
- start_stall_watch: whether to watch (MAM_STALL_WATCH, remote, human)
- _map_sub_rc, _rc_for (+ MAM_DELEGATE_FAIL_CLOSED), the subscriber
  early-exit check and cmd_wait's rc normalisation
- mam_int / mam_int_knob run lib_py/ints.py, which is also the runner's
  _positive_int: one implementation of the Issue #26 rule

No behaviour change: tests/test_issue17_item4_decisions.py runs the old
bash functions (copied verbatim from 7209750) and the new wrapper on the
same inputs and compares output, FINAL_JOB_STATUS and the registry /
publish calls. Three static tests that pinned the old bash literals now
pin the Python entry points instead.

Refs #17

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 04:59:58 +00:00
Godopu 7209750ac7 merge: fix/issue-45 (Issue #45 A+B: MAM herdr servers start with restore-on-restart off; resume closes its own restored empty shell; v4.8.5)
Closes #45. Reviewed in three rounds by claude, agy, grok and codex (4/4 PASS on ab93776); full suite 1456 passed / 0 failed.
2026-10-05 09:56:39 +00:00
GodopuandClaude Opus 5.5 bf8a583afb docs(v4.8.5): archive PR #48 reviews (three rounds) and record the merge-workspace review
PR #48 already carries the 4.8.5 lockstep bump. Merge-workspace review:
round 1 (@35935f9) claude agy grok NOT PASS (test portability: tomllib on
Python 3.9, /proc and /tmp on macOS), codex no verdict (model at capacity,
budget spent); fixed in 36f0b8d and e8d9151. Round 2 (@e8d9151) codex NOT
PASS (unrunnable config check passed an unchecked merge, odd pids closed a
pane, nan dropped the user's settings); fixed in ab93776. Round 3
(@ab93776) 4/4 PASS. Full suite at ab93776 on macOS / Python 3.9: 1456
passed / 0 failed / 38 skipped. Reports and the round-3 identity sidecars
go to .agents/reports/<session>/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-10-05 18:56:38 +09:00
GodopuandClaude Opus 5.5 ab93776d6d fix(issue-45): review round 2 - an unrunnable config check is not a pass, odd pids never close a pane, nan does not drop settings
codex round 2 (PR #48).

- R1: when `herdr config check` could not run (timeout, OSError), the
  merged config was handed to the server as checked. A merge with a schema
  error makes herdr drop the whole file, restore back on - the #45 failure.
  The merged body is no longer used unchecked: _write_checked falls to the
  fixed MINIMAL config with a warning. When the check cannot run on MINIMAL
  either, MINIMAL is still used (with its own warning) rather than no
  config: returning none would leave restore on, and MINIMAL is a fixed
  two-line file the real-herdr test accepts. Unknown non-zero exits stay as
  they were - herdr exits non-zero for harmless warnings (an unknown
  section) that do not drop the file.
- R2: _shell_in_foreground compared shell_pid and the foreground group with
  ==, so 0/0, False/False, ""/"" or -1/-1 closed the pane. Both must now be
  positive ints (bool excluded) before they are compared.
- R3: the re-parse check used ==, and nan != nan, so a valid TOML nan threw
  away every other user setting even on Python 3.11. _same_doc compares
  documents recursively with nan equal to nan.

Tests: R1 (timeout, OSError) and R3 (top-level, array, nested nan) in
test_issue45_herdr_config.py; R2 in the new tests/test_issue45_restore_unit.py
(a herdr stub: zero, false, empty, negative, null, string, true, float;
missing info; a real shell closed; a busy pane kept). On the previous code
12 of them fail. Python 3.11: 67 passed; Python 3.9: 33 passed / 34 skipped
(tomllib-only). Real-herdr restore tests and related files on 3.9: 197
passed / 1 skipped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-10-05 17:50:15 +09:00
GodopuandClaude Opus 5.5 e8d9151226 test(issue-45): review - the real-server tests run on macOS, and the bootstrap config check follows the bootstrap's python3
claude round 1, B2 and B3 (B1 was the previous commit).

- B2, tests/test_issue45_restore.py: the throwaway root is resolved, because
  on macOS /tmp links to /private/tmp and herdr reports real paths (two B
  tests compared /tmp/... with /private/tmp/...). The server's environment
  is read from /proc on Linux and from `ps -E` elsewhere (macOS has no /proc;
  two A tests failed with FileNotFoundError).
- B3, tests/test_issue9_server_env.py: the bootstrap builds the config with
  the python3 on PATH, which here is 3.9 without tomllib, so the test's
  merged-settings expectation could not hold and its own `import tomllib`
  failed. The test now parses the result with that same python3; when it
  has no tomllib, it expects the key-only config and the bootstrap's
  "no tomllib" warning instead.

macOS, Python 3.9 venv: test_issue45_restore, test_issue9_server_env and
test_issue45_herdr_config 42 passed / 29 skipped (the tomllib-only checks).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-10-05 15:59:26 +09:00
GodopuandClaude Opus 5.5 36f0b8dfd6 test(issue-45): the herdr-config tests collect on Python < 3.11; the docs say what that Python loses
tests/test_issue45_herdr_config.py imported tomllib at module level. On
Python 3.9 - macOS's /usr/bin/python3, which the venvs and the bootstrap's
python3 use here - the import failed during collection and pytest ran no
test of the whole suite.

The import is optional now, and the 29 tests that parse a result with
tomllib or expect the user's settings kept are marked needs_tomllib: on
Python < 3.11 herdr_config deliberately writes the key-only config (restore
still off, the other settings dropped, a warning on stderr), which
test_without_tomllib_restore_is_still_off covers on every Python.
Python 3.9: 22 passed / 29 skipped; Python 3.11: 51 passed.

create SKILL.md (same line, so the #42 inventory is unchanged) and the
4.8.5 entry state that building the server config needs tomllib (Python
3.11+) in the python3 on PATH, and what an older python3 loses. Checked
with Python 3.9 in a throwaway HOME: the warning reaches stderr (the
bootstrap captures only stdout) and the user's file is untouched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-10-05 15:56:27 +09:00
GodopuandClaude Opus 5.5 35935f9e8a fix(issue-45): review round 2 R3 - the .mam fallback obeys the same no-overwrite rules
The fallback wrote <.mam>/herdr-server-config.toml unchecked, so it could
replace a foreign file there, the source itself, or a link to it. It now goes
through output_path like the normal path (source identity incl. aliases,
generated-for-this-source ownership, then a per-source name) and writes
nothing when no name is safe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:49:08 +00:00
GodopuandClaude Opus 5.5 7c544c06ab test(issue-45): S11 base follows the review-R1 heredoc change; VERSIONS notes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:18:09 +00:00
GodopuandClaude Opus 5.5 811b080966 fix(issue-45): review R1-R3 - herdr checks the config, any TOML spelling kept, source never overwritten
R1: tomllib accepting a file is not herdr accepting it (a schema error makes
herdr drop the whole file, restore included). The shim passes REAL_HERDR and
the written file must pass `herdr config check` ("parse error"/"using defaults"
reject; unknown keys and sound-file notices do not), else the key-only file is
written and checked. A non-table `session` and a source that is not UTF-8 reach
that fallback instead of raising.

R2: the line edit is replaced by parse -> set -> serialize; the result must
parse back to the source plus the key, so dotted keys and inline tables keep
the user's other settings.

R3: the output is never the source (aliases included) nor a file not generated
for the same source; then a per-source name beside it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:17:59 +00:00
GodopuandClaude Opus 5.5 ce91cc11b9 release(v4.8.5): bump framework and eight skills to 4.8.5 in lockstep
Issue #45 A+B: MAM's isolated herdr servers start with restore-on-restart off,
and resume closes the session's own restored empty shell. Not reviewed yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:53:14 +00:00
GodopuandClaude Opus 5.5 eedcebcc12 fix(issue-45): restore stays off when python3 has no tomllib
The shim runs a bare python3. Before 3.11 the module failed to import and the
server started with restore on; now it gets the key alone, with a warning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:34:27 +00:00
GodopuandClaude Opus 5.5 feceddf49f docs(issue-45): isolation and resume notes; block inventory line shifts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:32:49 +00:00
GodopuandClaude Opus 5.5 fb1380fdc1 fix(issue-45): resume closes the session's own restored empty shell
After a restart with restore off, the session's saved pane comes back as an
empty shell (in $HOME when its directory is gone) holding the agent_session it
reported. A resume with a CP_PREFIX cannot reuse it, so it stayed next to the
new pane. Just before spawning, resume closes panes whose agent_session is the
id being resumed, with no live agent and only the shell in the foreground; the
registry is not consulted, since a session whose server died stays running.

tests/test_issue45_restore.py drives a real herdr server under a throwaway HOME
and session with a fake claude: restore stays off through the MAM bootstrap
(with a restore-on control), the close conditions, and create -> crash ->
resume leaving one pane.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:32:49 +00:00
GodopuandClaude Opus 5.5 933af344c2 test(issue-45): S11 compares the shim heredoc with the Issue #45 bootstrap commit
S11 keeps the generated .mam/shim/herdr from changing by accident; #45 changes
it on purpose, so its base moves to ed73f67.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:32:49 +00:00
GodopuandClaude Opus 5.5 ed73f67a4f fix(issue-45): the shim bootstrap starts MAM servers with restore-on-restart off
The headless bootstrap regenerates the server config on every start and hands
it to the server as HERDR_CONFIG_PATH. Without a file the server still starts,
with a warning. mam_sandbox unsets HERDR_CONFIG_PATH/XDG_CONFIG_HOME so the
file lands in the sandbox HOME.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:32:35 +00:00
GodopuandClaude Opus 5.5 a640b0f072 feat(issue-45): herdr config for MAM servers with restore-on-restart off
lib_py/herdr_config.py builds the user's herdr config (HERDR_CONFIG_PATH, else
XDG_CONFIG_HOME, else ~/.config, as herdr reads it) with only
[session] resume_agents_on_restore forced to false, written next to the source
so relative paths resolve the same. The text merge is re-parsed; a merge that
does not leave the rest equal falls back to the key alone, since herdr drops a
config that does not parse and would restore again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:32:35 +00:00
Godopu c5c7eff6f7 merge: fix/issue-42-skills-link (.claude/skills check takes both local layouts; v4.8.4)
Reviewed in two rounds by claude, agy, grok and codex (4/4 PASS on 150b493).
2026-10-04 04:54:58 +00:00
GodopuandClaude Opus 5.5 65dc7fde82 release(v4.8.4): bump framework and eight skills to 4.8.4 in lockstep; archive PR #47 reviews (two rounds)
The .claude/skills check accepts both local layouts and fails on a dangling
link. Review: round 1 (@e1ed270) claude agy grok PASS, codex NOT PASS (a
dangling top-level link was skipped); fixed in 150b493; round 2 4/4 PASS.
Reports and the round-2 identity sidecars go to .agents/reports/<session>/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-10-04 13:54:57 +09:00
GodopuandClaude Opus 5.5 150b4937f8 test(issue-42): review R1 - a dangling .claude/skills link fails instead of skipping
Path.exists() follows the link, so a dangling top-level .claude/skills read
as "absent" and the test skipped: every skill's load path could be broken
with the suite still green (codex R1 on PR #47; the exists() branch predates
this PR).

The check moves into claude_skills_problems(link, skills_dir, names):
absence is "neither exists nor is a symlink"; a symlink must resolve to
.agents/skills; a directory must hold each skill resolving to the matching
.agents/skills/<skill>. test_claude_skills_layouts runs it on synthetic
layouts (absent, single link, per-skill links, a link to the wrong skill, a
dangling top-level link, a dangling per-skill link), so it is exercised in
a worktree too. Replaying the review's probe (a dangling .claude/skills in
the tree) now fails; the main checkout's per-skill layout reports nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-10-04 13:46:41 +09:00
GodopuandClaude Opus 5.5 e1ed2708d8 test(issue-42): the .claude/skills check accepts both local layouts
test_claude_skills_link_resolves_to_agents_skills assumed .claude/skills is
one symlink to .agents/skills. It is local and git-ignored, and a checkout
may instead hold a directory with one link per skill (this workspace's
layout), where every link is correct but the test failed. PR #46's reviews
and suites ran in worktrees, where the directory is absent and the test
skips, so the failure only showed in the main checkout after the merge.

The test now accepts a single link to .agents/skills, or a directory whose
entry for each of the eight skills resolves to the matching
.agents/skills/<skill>. Checked against no link (skip), per-skill links
(pass; the old test fails), one link pointing at the wrong skill (fail) and
a single link (pass).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-10-04 08:38:01 +09:00
Godopu 6af4a796b1 merge: fix/issue-42 (Issue #42 stages 1-2: SKILL.md code-block inventory, duplicate blocks removed; v4.8.3)
Refs #42 (stages 1-2). Reviewed in two rounds by claude, agy, grok and codex (4/4 PASS on 17eba09); full suite 1408 passed / 0 failed.
2026-10-03 23:36:27 +00:00
GodopuandClaude Opus 5.5 44999ff2fb release(v4.8.3): bump framework and eight skills to 4.8.3 in lockstep; archive PR #46 reviews (two rounds)
Issue #42 stages 1-2: the SKILL.md code-block inventory as test data and
the removal of blocks the entry scripts already do. Merge-workspace review:
round 1 (@8d26106) claude agy grok PASS, codex NOT PASS (R1-R3); fixed in
17eba09 (docs, inventory data and the guard test only); round 2 4/4 PASS.
Full suite at 8d26106 1408 passed / 0 failed. Reports and the round-2
identity sidecars go to .agents/reports/<session>/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-10-04 08:36:15 +09:00
GodopuandClaude Opus 5.5 17eba0904e docs(issue-42): review - attach names the herdr server, --dry-run is not "write nothing", a deleted block stays deleted
Merge-workspace review of PR #46 (codex R1-R3 and C1, claude O1 and O4).

- R1: resume never attaches, and the server it exports does not reach the
  calling shell; the native herdr binary does not route on environment
  variables. The attach advice and the Verification block now name the
  server: `herdr --session <server> agent attach <name>`, with <server> the
  row's herdr_session.
- R2: --dry-run in resume and create skips the spawn and the registry
  update, but the checks before it still run and on macOS remove the
  quarantine attribute from the agent binary. The option rows say so.
- R3: test_no_deleted_body_came_back compares every current block with the
  deleted bodies by exact sha and by a whitespace-insensitive fingerprint
  (stored as base_fingerprint), independently of the inventory, so a
  deleted block cannot return by being listed again under class c.
  Replaying the review's probe (the deleted resume Workflow re-added and
  inventoried as c) fails the new test and passes the old one.
- C1 / O1: the base create call block at 263-266 (class c) folded into the
  Workflow call is recorded under "superseded"; the 37 base blocks are all
  accounted for.
- O4: create exit 42 also covers MAM_KILL_ON_READY_TIMEOUT=1; stop's no-op
  exit 0 does not apply with --purge-conversation.
- The inventory's positions and shas are regenerated for the edited blocks;
  every classification is unchanged.

No product code changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-10-04 08:06:32 +09:00
GodopuandClaude Opus 5.5 8d26106195 test(issue-42): review R2 - a passthrough code must be in the exit table
raised == passthrough forced the declaration, but nothing checked the
declared code against the table: dropping create's exit 4 from both the
table and the data still passed. Assert passthrough <= table codes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 14:10:59 +00:00
GodopuandClaude Opus 5.5 e6a46cd7c4 fix(issue-42): review R1 - stop's exit 1 also comes after the stop
The registry update after the kill chain raises SystemExit(1) when the row
disappeared during the run (stop_session.sh:310), so "exit 1 changed nothing"
was false on that path. The stop table now says so, the code is declared as a
passthrough, and the test requires every integer SystemExit in an entry
script's heredocs to be declared - the check that would have caught it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 13:56:36 +00:00
GodopuandClaude Opus 5.5 7670b41afd docs(issue-42): stages 1-2 - SKILL.md code-block inventory as test data; remove blocks the scripts already do
Stage 1: tests/data/skill_code_blocks.json lists all 37 fenced blocks of the
eight SKILL.md files at base 45d1621 (indented fences count), classed (a)
duplicate of a script, (b) procedure no script offers, (c) example/call/
diagram, with script file:line evidence and recorded mismatches.
tests/test_issue42_skill_blocks.py fails when a block changes without the
inventory, an (a) block comes back, a removed block's entry call is missing,
an exit-code table drifts from the script, or evidence points nowhere.

Stage 2: the five (a) blocks are gone - resume Workflow, stop pre-flight,
create pre-flight / Workflow / manual YAML append - replaced by the
entry-point call with option and exit-code tables. Each exit table has a
catch-all row: under set -e a failing sub-step's code passes through.
create's exit 4 is real (the YAML write raises SystemExit(4) for a duplicate
running row) and is listed as such. Doc-to-script mismatches are fixed in the
doc (resume Case 3 / no attach, stop --purge-conversation --yes, status
--json keys, create cmd_full and the nonexistent yaml-edit script). No
behaviour change, no version bump.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 13:42:08 +00:00
Godopu 45d1621c93 merge: fix/followups-41-43 (PR #41/#43 review follow-ups; v4.8.2)
Reviewed in two rounds by claude, agy, grok and codex (4/4 PASS on 75f364e); full suite 1380 passed / 0 failed.
2026-10-02 15:27:28 +00:00
GodopuandClaude Opus 5.5 3563a6fc17 release(v4.8.2): bump framework and eight skills to 4.8.2 in lockstep; archive the follow-up loop's reports
PR #41/#43 review follow-ups F1-F6, implemented by creator-claude-01 in a
loop and reviewed by claude, agy, grok and codex: round 1 (@95eabc6)
codex NOT PASS (a failed env load cached as loaded), fixed in 75f364e;
round 2 4/4 PASS. Full suite at 75f364e 1380 passed / 0 failed. Reviewer
reports (round 2 with identity sidecars) and the creator's reports go to
.agents/reports/<session>/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-10-03 00:27:24 +09:00
GodopuandClaude Opus 5.5 75f364e4b4 fix(followups): F2 review R1 - a failed env load is not cached as loaded
ensure_env_loaded() set _env_loaded before _load_dotenv() ran. With F2,
load_env_or_report() turns the EnvFileError into a return value, so a second
main() in the same process skipped the unreadable file and ran unconfigured
(codex R1: registry.main twice -> rc 1 then rc 0 with a job registered; a
repaired file was never read). The flag is now set only after a successful
load: a failure is retried on the next call, a success still loads once.
envfile parses the whole file before applying, so a failed load sets nothing.

MESSAGING.md: the one-line ERROR is the Python entry points'; the wrapper
adds its own "ERROR: cannot load <path>" line (codex, minor).

Test: in one process, registry.main twice on an undecodable file is rc 1
both times, prints the ERROR line twice and registers nothing; the repaired
file is applied on the next call; a later change is not re-read. At 95eabc6
it fails with rc [1, 0], one job registered and the value unset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGpJXhSZasJLU38ksDghe
2026-10-02 09:29:11 +09:00
GodopuandClaude Opus 5.5 95eabc693f fix(followups): F2 - job_subscriber returns the env-file failure by name
5edf913 gave job_subscriber.main a bare `return 1`; its exit codes are
returned by name (test_issue5 test_job_subscriber_cli_contract_snapshot).
The value stays 1 - what the EnvFileError traceback exited with before -
and is RC_ERROR. The wrapper reads the same file first and exits 1 itself,
so it never starts a subscriber that would report this.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGpJXhSZasJLU38ksDghe
2026-10-02 08:39:22 +09:00
GodopuandClaude Opus 5.5 1e92ddc563 test(followups): F6 - the wrapper vanishes inside the second wait; the <= 60 s is the herdr wait's
delegate-job SKILL.md said a watcher outlives a killed wrapper by "one
wait, <= 60 s". That bound is the herdr wait's; a wrapper that goes while a
notice is being published is noticed when that publish ends. The text now
says both (PR #41 codex round 3).

"a wrapper gone during the wait" switched alive() by call count, so the
watcher saw the wrapper gone at the next loop head and never entered the
second wait (ticks == [1] with that band). The wrapper now vanishes inside
the second herdr wait (_during_notice's in_wait hook), and the test checks
that the second wait happened and that alive() was true until then. The
same test fails when the post-wait _wrapper_gone() check is removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGpJXhSZasJLU38ksDghe
2026-10-02 08:23:31 +09:00
GodopuandClaude Opus 5.5 c252ac4d54 fix(followups): F5 - no test switch in the stall watcher
stall_watch read MAM_TEST_UNRELIABLE_AGENT to pretend an adapter's
agent_status was unreliable (PR #41 M7). The branch is gone. S13 now runs
the real stall_watch.main() in a process where the adapter itself reports
agent_status_reliable = False (_UNRELIABLE_MAIN), and checks the same
things as before: no idle notice or nudge, the blocked notice, and the
"idle judgement off" log line. A static check keeps MAM_TEST_ out of the
watcher (fails at 0051fd6). Without the seam S13 fails on this tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGpJXhSZasJLU38ksDghe
2026-10-02 08:23:31 +09:00
GodopuandClaude Opus 5.5 fb1b93c665 fix(followups): F4b - bash integer knobs that reach [ -ge/-le/-gt ] or sleep go through mam_int_knob
A non-numeric MAM_READY_EMPTY_GIVEUP, SKS_EMPTY_GIVEUP, SKS_IDLE_RETRIES,
SKS_IDLE_RETRY_BACKOFF_S, MAM_LOCK_STEAL_RETRIES, MAM_DIFF_MAX_BYTES,
MAM_DIFF_MAX_LINES or RECONCILE_POLL_INTERVAL was a `[` error or a sleep
error at the point of use (PR #43 review claude R3). They now go through
mam_int_knob: decimal, a warning and the default for a bad value. Minimums
are in test_issue26_ints.py INT_KNOBS: 1 for the give-up counts (0 acts as
1), the diff caps (0 truncates every diff) and the poll interval (0 is a
busy loop); 0 for the retry counts (one attempt) and the retry backoff.

SKS_QUIESCENT_INTERVAL stays raw: its default is 0.5 s and sleep's
fraction is its meaning; NOT_ARITHMETIC says so.

loop_lock.sh and diff_collect.sh also run without lib.sh (the Python runner
and the tests source them alone), so mam_int / mam_int_knob move verbatim
to lib_int.sh, which lib.sh sources and those two source when the function
is missing. remove.sh's no-manifest fallback list names the new file
(install copies every file under .agents/skills). test_issue21_loop_b l15,
which builds a control root holding only diff_collect.sh, copies it too.

Tests: the table guard now reads every shell file; scripts sourced alone
read their knobs as decimal; _pane_quiescent with SKS_EMPTY_GIVEUP=abc
gives up with a warning and no `[` error (14 of the new cases fail at
0051fd6).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGpJXhSZasJLU38ksDghe
2026-10-02 08:23:31 +09:00
GodopuandClaude Opus 5.5 8b046390be fix(followups): F4a/F4c - the runner's wait knobs follow the integer rule; MAM_WAIT_GRACE_S documented
MAM_WAIT_GRACE_S silently fell back to 300 on anything but bare digits
(" 30 " too), MAM_CHILD_WAIT_S went through float() (abc raised ValueError,
0.5 and 1e3 were taken) and MAM_JOB_TREE_MAX through int() (abc and an
empty value raised). All three now go through runner._int_knob, which is
_positive_int's rule (PR #43 review grok): decimal, blanks around allowed,
an invalid value warns once and the default is used. Minimums:
MAM_WAIT_GRACE_S >= 0 (wait exactly the budget), MAM_CHILD_WAIT_S >= 0
(look once; test_issue34 uses 0), MAM_JOB_TREE_MAX >= 1 (a cap of 0 calls
every tree "over"; not waiting is MAM_CHILD_WAIT_S=0).

The loop SKILL.md now names MAM_WAIT_GRACE_S in the budget row and the
sub-job paragraph (PR #41 M6), with the knobs' integer rules.

Tests: RUNNER_INT_KNOBS in test_issue26_ints.py holds the minimums; the
real wait_for_descendants is driven per knob, and a static check ties the
call-site minimums to the table (15 of the new cases fail at 0051fd6; the
14 that pass are valid values whose result did not change).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGpJXhSZasJLU38ksDghe
2026-10-02 08:13:44 +09:00
GodopuandClaude Opus 5.5 71ea8095c5 fix(followups): F3 - .mam.env BOM, repeated keys and a missing MAM_ENV_FILE
PR #43 review (grok):
(a) A UTF-8 byte-order mark at the start of the file made the first line an
    "invalid key", so its setting was dropped. The one parser (envfile.py,
    used by the wrapper and mqtt_common) now reads with utf-8-sig.
(b) A key given twice keeps its first value (a later line does not replace
    it). The behaviour is unchanged; .mam.env.example's FORMAT section and
    envfile.py's docstring now say so.
(c) MESSAGING.md said a MAM_ENV_FILE naming no file logs an error and makes
    connections fail closed. Both loaders skip it silently and refuse the
    ambient search; a file that exists but cannot be read is the error
    (EnvFileError, one ERROR line and rc 1). The text now says that.

Tests A7: BOM in both loaders, first-value-wins in both loaders plus the
FORMAT text, and a missing explicit file loading neither it nor the ambient
.mam.env without an error plus the MESSAGING text (all three fail at 0051fd6).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGpJXhSZasJLU38ksDghe
2026-10-02 08:10:42 +09:00
GodopuandClaude Opus 5.5 5edf913d22 fix(followups): F2 - Python entry points report an unreadable env file in one line
A .mam.env that exists but cannot be read or decoded stopped the Python
entry points with an EnvFileError traceback, while the wrapper printed one
`ERROR:` line and exited 1 (PR #43 review claude S1). The mains that load the
env file - publish_event, registry, job_subscriber, cli, bench, heartbeat and
remote - now call mqtt_common.load_env_or_report(), which prints
`ERROR: <path>: cannot read (<exception type>)` and lets the main return 1.
No value or file content is printed. ensure_env_loaded() still raises: no
library function exits the process.

Test: every entry point, for a 000 file and an undecodable one, ends with
exactly that line and rc 1 and creates nothing (14 cases; all 14 fail at
0051fd6 with a traceback).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGpJXhSZasJLU38ksDghe
2026-10-02 08:09:46 +09:00
GodopuandClaude Opus 5.5 75936f11c6 test(followups): F1 - A8 writes the marker lstart as loop_lock.sh reads it
`ps -o lstart=` pads a one-digit day ("Oct  1"); loop_lock.sh's mam_lstart
squeezes the spaces before comparing, but A8 wrote the raw value, so the
live holder's marker looked stale and the second loop was not refused on
the 1st-9th of every month. The test now normalises like the product (the
product is unchanged) and also runs with a `ps` shim that always prints a
padded lstart, so the padded case is checked on every date.

The other tests that record a live lstart (test_o3_scoped_guard
_get_lstart) already normalise; test_o2's markers carry fixed stale values.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGpJXhSZasJLU38ksDghe
2026-10-02 08:08:26 +09:00
Godopu 0051fd6c3b merge: fix/issue-26 (Issue #26 one .mam.env parser, decimal integer knobs, tests on a local MQTT broker; v4.8.1)
Closes #26. Reviewed in two rounds by claude, agy, grok and codex (4/4 PASS on 0eec795).
2026-10-01 15:23:26 +00:00
GodopuandClaude Opus 5.5 2e90c809d3 release(v4.8.1): bump framework and eight skills to 4.8.1 in lockstep; archive PR #43 reviews (two rounds)
Issue #26: one .mam.env parser for the wrapper and the Python tools,
decimal integer knobs, tests on a local MQTT broker. Merge-workspace
review: round 1 (@b58edac) claude agy PASS, codex NOT PASS (R1-R4), grok
no verdict (its late report, archived as report-c9ed5b14.md, found R1 too);
round 2 (@0eec795) 4/4 PASS. Reports and round-2 identity sidecars go to
.agents/reports/<session>/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-10-02 00:23:22 +09:00
GodopuandClaude Opus 5.5 0eec795cd1 fix(issue-26): review - an unreadable env file is an error, NUL lines are skipped, integers are ASCII in every locale, empty flags are refused
Merge-workspace review of PR #43 (codex R1-R4, claude R1/R2).

- R1: envfile.parse raises EnvFileError when the file exists but cannot be
  read or decoded as a whole; `--shell` prints the cause and exits 1, so the
  wrapper stops (rc 1, as before #26) instead of running on defaults, and
  mqtt_common's entry points stop on the same error. A bad single line is
  still a warning. The wrapper's message no longer claims a read failure
  when the cause is elsewhere ("cannot load ... see the line above").
- R2: lib.sh mam_int spells out the ASCII blanks and digits instead of
  [[:space:]]/[0-9], which follow the locale: U+2003 30 U+2003 was 30 in the
  wrapper and invalid in the runner under en_US.UTF-8. The test oracle
  _expected uses the same ASCII rule.
- R3: an integer flag given explicitly with an empty value is refused like
  any other invalid value; only an absent flag keeps its default.
- R4: a line holding a NUL byte is skipped with a warning by both loaders;
  before, the wrapper read "leftright" and the Python loader died.
- .mam.env.example states the format: no expansion, `export` and bash's own
  variables skipped, a trailing `# ...` is part of the value, the environment
  wins, an unreadable file is an error.

Tests: unreadable (0000) and invalid-UTF-8 files stop the wrapper and the
Python loader; a NUL line is skipped by both; Unicode blanks/digits are
invalid for mam_int, _positive_int, the wrapper and the runner under C and
en_US.UTF-8; explicit empty values for the five integer flags are refused.
All of them fail on b58edac.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-10-01 22:59:57 +09:00
GodopuandClaude Opus 5.5 b58edace11 test(issue-26): C0 reads broker state only after the matching ack; the local broker acks a PUBLISH after routing it
Review (fa45b0e6): paho's connect() does not wait for the CONNACK, so C0
could read protocol_levels before the broker had handled the second
CONNECT, and the `before` slice could take in a previous test's connection.

- _Client waits for CONNACK (and UNSUBACK, instead of sleeping 0.3 s);
  C0 checks the levels of its own two client ids (stats() levels_by_client).
- The local broker now routes and stores a PUBLISH before its PUBACK, so a
  publisher holding the ack may rely on the retained state it asked for
  (late subscriber replay, retained delete).
- test_c0_holds_when_the_broker_is_slow runs C0 with CONNECT/UNSUBSCRIBE
  delayed and PUBLISH routed late; the old C0 fails it ([] == [4, 4]), as do
  an ack-before-route broker and a fixed-sleep unsubscribe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 01:04:19 +00:00
GodopuandClaude Opus 5.5 fb006374d6 test(issue-26): test_bug4 checks the whole send_keys_safe body, not its first 2500 characters
34241d7 added a line near the top of send_keys_safe (SKS_QUIESCENT_TRIES
normalisation), which pushed `agent prompt` from inside the 2500-character
window to offset 2519. The order the test guards is unchanged
(_pane_quiescent < _pane_dialog_open < agent prompt); it now slices up to
the function's closing brace.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:13:40 +00:00
GodopuandClaude Opus 5.5 34241d7a65 fix(issue-26): review round 1 - no file key assigns bash's own variables; SKS_QUIESCENT_TRIES; long decimals
R1 (P1): a quoted export still ran code when the key was a bash special
variable (RANDOM=a[$(cmd)] is evaluated arithmetically on assignment).
envfile.RESERVED (bash's own variables, plus BASH_/COMP_/READLINE_
prefixes) is skipped with a line-numbered warning by both loaders; a test
keeps it covering every variable a clean bash defines. The wrapper also
hands SCRIPT_DIR/REPO_ROOT/TARGET_ENV to the parser as environment, so the
file cannot replace the variables it has already set.

R2 (P2): SKS_QUIESCENT_TRIES reached `for ((i < tries))` through a function
argument; it is normalised at the call site (minimum 0). B5 now requires
every integer-looking env default in the shell code to be normalised or
listed in NOT_ARITHMETIC with where it goes, and exercises _pane_quiescent.

R3 (P2): _positive_int strips leading zeros and rejects more than nine
significant digits before int(), like mam_int; 5000-digit inputs no longer
raise in the runner.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 17:39:08 +00:00
GodopuandClaude Opus 5.5 2826e709ae test(issue-26): no test depends on a third-party MQTT broker
- tests/_local_mqtt.py: in-process MQTT 3.1.1 broker on 127.0.0.1 (what
  the repo uses: protocol level 4, clean session, QoS 0/1, retained).
- conftest: one session broker named by MQTT_BROKER/MQTT_PORT/MQTT_TLS;
  other redirecting/auth MQTT_* dropped and restored afterwards.
- Harnesses of the 22 broker-dependent tests (#18 acceptance1/2/3/5,
  #6 t1_1/2/7/8/14/15/16/20, #19 MQTT loop x2, #33 b3/r3/b7, #30
  s9/s10/r2) use it; the "broker unreachable" skips are gone (a local
  broker that does not deliver is a failure). acceptance1/5 and #19 also
  assert on the subscriber, runner and audit logs the real producers write.
- tests/test_local_mqtt.py: conformance with the real clients and the real
  publish_event -> job_subscriber path (C0), an AST guard against public
  broker literals and MQTT_* deleted without a replacement (C2), and the
  session's broker config (C3).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:54:58 +00:00
GodopuandClaude Opus 5.5 80dc2eb45e fix(issue-26): integer knobs that reach bash arithmetic are read as decimal
bash read MAM_JOB_TIMEOUT_S=0700 as octal 448 (and failed on 0900) while
the loop runner read 700. One rule now, with a per-knob minimum:
lib.sh mam_int / mam_int_knob and runner._positive_int (blanks allowed,
digits only, decimal, min <= n <= 999999999).

- minimum 1: MAM_JOB_TIMEOUT_S, MAM_HEARTBEAT_SEC, MAM_HEARTBEAT_MISSES,
  MAM_ACK_TIMEOUT_S (invalid env: warning + default) and the wrapper's
  --timeout/--idle-timeout/--heartbeat/--heartbeat-misses/--ack-timeout
  (invalid: rc 1); the runner's job_budget, --job-timeout, contract_args.
- minimum 0 (0 = check once): MAM_INJECT_ACK_GRACE_S,
  delegate_wait_started's ack_s, SKS_DIALOG_TIMEOUT, SKS_BUSY_TIMEOUT,
  SKS_DELIVERY_GRACE_S.
- tests/test_issue26_ints.py: INT_KNOBS is the one table of minimums; a
  static guard keeps env defaults out of arithmetic and checks each
  normalised knob against it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:47:40 +00:00
GodopuandClaude Opus 5.5 f1331d36ce fix(issue-26): the wrapper reads .mam.env through the Python loader's parser, never source
- lib_py/delegation/envfile.py (stdlib only) holds the one set of rules:
  comments and blank lines skipped, key and value stripped, one matching
  quote pair removed, no shell interpretation, keys must match
  ^[A-Za-z_][A-Za-z0-9_]*$ (bad or `=`-less lines warn with the line
  number, never the value).
- mqtt_common._parse_env_file delegates to envfile.apply.
- The delegate-job wrapper runs envfile.py --shell by path and evals the
  shlex-quoted exports; values never reach a command line. The environment
  now wins over the file in both loaders.
- .mam.env.example: commented assignments no longer use $HOME, which the
  parser keeps literally.

The shell parsed the env file in one place only (wrapper :38), not three.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:47:40 +00:00
Godopu e1eb22bd59 merge: fix/issue-30-33 (Issue #33 budget + wall_clock_timeout, Issue #30 notify-only stall watcher; v4.8.0)
Closes #30 #33. Reviewed in three rounds by claude, agy, grok and codex (4/4 PASS on 9200562); release 53f87e4.
2026-09-26 07:08:46 +00:00
GodopuandClaude Opus 5.5 53f87e4560 release(v4.8.0): bump framework and eight skills to 4.8.0 in lockstep; archive PR #41 reviews (three rounds)
Issue #33 (job budget, wall_clock_timeout vs deadline_timeout) and
Issue #30 (notify-only stall watcher). Reviews of PR #41 in the merge
workspace: round 1 (@1673818) claude N1 / codex R1 R2 NOT PASS, agy grok
PASS; round 2 (@af0a2a2) grok N1 / codex D1 D2 NOT PASS, claude agy PASS;
round 3 (@9200562) 4/4 PASS. Reports and the round-3 identity sidecars go
to .agents/reports/<session>/. The design consensus on items A-D is in the
VERSIONS.md entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-09-26 16:08:37 +09:00
GodopuandClaude Opus 5.5 9200562c91 fix(issue-30): review round 2 - the watcher is stopped with KILL, and a strict last gate sits between the notice and the keys
grok N1: the wrapper spent up to 5 s on a TERM budget before it finalized the
job, so when the watcher swallowed the TERM the silent job's close came ~5 s
late and a `completed` written in that gap won (test_issue14 idle-grace).
codex D1: `_notify` blocks on a publish for up to 120 s, and the prompt went
out right after it; a completed, a re-dispatch or an unreadable record in that
window still got the nudge. codex D2: the wrapper pid was checked only at the
loop head, so a watcher whose wrapper vanished during a wait could still
notify and nudge once.

- multi-agent-mux-delegate-job `stop_stall_watch`: KILL + wait, no budget.
  The watcher holds nothing to flush (its TERM handler is a bare os._exit),
  and KILL cannot be swallowed.
- lib_py/stall_watch.py: `_wrapper_gone()` after every wait (before any
  notice) and at the loop head; `_may_prompt()` right before the keys - the
  wrapper is alive and the record, read then, is open and this dispatch;
  `_dispatch_open(strict=True)` treats an unreadable record as "no".
- SKILL.md: the lifetime contract states the one-wait lag and that nothing is
  sent after the wrapper is seen gone.
- tests/test_issue30_stall_watch_stop.py: the stop returns at once against a
  TERM-ignoring stand-in (took 6 s on the round-2 code); completed,
  re-dispatch and a torn record during the notice publish withhold the nudge;
  a wrapper gone during the wait gets no notice and no nudge. All five fail
  on the round-2 code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-09-26 15:28:07 +09:00
GodopuandClaude Fable 5.1 af0a2a2557 fix(issue-30): review - the stall watcher's lifetime is bounded on both sides and it re-checks its dispatch before acting
Round-1 review of PR #41 (claude N1, codex R1/R2).

N1 - the wrapper stopped its stall watcher with `kill` followed by an
unbounded `wait`. A TERM that lands in the watcher's fork-exec window is
swallowed (bash defers terminating signals in the forked child until the
exec), the watcher then installs its own handler and lives until the job
closes, and the wrapper hung until someone killed the watcher by hand
(reproduced twice in test_issue14's ack test; the wrapper trace stops at
`kill` + `wait`).
R1 - a wrapper that is itself terminated left the watcher orphaned.
R2 - a `completed` or a re-dispatch that landed during a blocking herdr
wait (up to 60 s) was still followed by the idle notice and the nudge.

- multi-agent-mux-delegate-job: `stop_stall_watch` sends TERM, polls up
  to 5 s, then KILLs and reports "ignored TERM; killed"; `start_stall_watch`
  passes `--watch-pid $$`.
- lib_py/stall_watch.py: `--watch-pid` (rc 0 once that pid is gone, checked
  between herdr calls); `_dispatch_open()` re-reads the record after every
  wait and right before the nudge - terminal status, another dispatch token
  or attempt, or a missing record ends the run without a notice; a refused
  `stalled:idle` publish withholds the nudge (`_notify` returns False).
- delegate-job SKILL.md: the watcher's lifetime contract; exit-code rows 2
  and 3 name the idle clock and the wall-clock label; "before v4.7.5" ->
  v4.8.0 (M5, NB2).
- tests/test_issue30_stall_watch.py (M8): the module-wide Linux mark is
  narrowed to S6 (/proc); r4's stub stamps time.time() so its cross-process
  comparison holds on macOS too (47 passed, 1 skipped here).
- tests/test_issue30_stall_watch_stop.py (new, macOS and Linux): the stop
  routine against a TERM-ignoring stand-in returns in 4-12 s; the wrapper
  returns (rc 3) with a TERM-ignoring watcher shim; `--watch-pid` ends the
  watcher; a TERM to the real wrapper leaves no watcher; a job completed or
  re-dispatched during the wait is not nudged; a refused notice withholds
  the nudge. Every one of them hangs or fails on the round-1 code.

Not in this commit (follow-ups): M7 test hook in product code (no adapter
is unreliable, a seam is needed), M9/B3 public-broker latency (on this host
even `started` sometimes never reaches the subscriber), the leading-zero
budget parse (NB1) and M1/M2/M4/M6 go on the #17 item 4 list.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-09-26 13:17:24 +09:00
GodopuandClaude Opus 5.5 1673818bb5 fix(issue-33): subscriber exit codes are named on both sides; wait keeps a spent budget at rc 2; G-8 is deterministic
- job_subscriber.py returns only named codes (RC_COMPLETED 0, RC_ERROR 1,
  RC_NO_EVENT 2, RC_BROKER_UNAVAILABLE 3, RC_WALL_CLOCK 4). The wrapper
  mirrors them as SUB_RC_* and compares subscriber codes only by name; a test
  keeps the mirror equal to the Python constants and forbids numeric
  comparisons of $sub_rc/$sub_exit and numeric `return` in the subscriber.
- `delegate-job wait` passed the subscriber's code straight through, so a
  spent budget would have reached its callers as 4; it is 2 again (B7 fails
  without the mapping).
- tier1 G-8 gave both clocks 0.5 s and raced; the wall clock now runs out
  first by construction (idle 30 s) and the test asserts RC_WALL_CLOCK.
  Other subscriber rc asserts use the names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 00:22:43 +00:00
GodopuandClaude Opus 5.5 9ec0569401 fix(issue-30): review ad3d8206 - a prompt glyph inside a draft is draft text; the R4 regression test tells the defect apart
- input_region: in the prompt-line shape the input box starts only at a
  column-0 prompt (codex indents a draft's continuation lines), and the
  prompt and the (whole-line) placeholder are stripped from the box's first
  non-blank line only, so `› Explain this terminal prompt:\n  ›` is not empty.
- test_r4 runs with S=40 again: the defective code nudged ~30 s after a
  missed transition, which S=10 could not tell from the fixed ~10 s.

Both new checks fail against the defective versions (stall_watch.py at
47e4274 -> 30.24 s < 40; input_region.py at bf0f5c1 -> nudge sent) and pass
now, each with its passing twin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 16:02:39 +00:00
GodopuandClaude Opus 5.5 bf0f5c1014 fix(issue-30-33): review 5ec8ef33 - a multi-line codex draft is not an empty box, blocked notices reach the loop while the job runs, and both clocks are labelled on every close path
- R1 (P1): without rules the input region runs from the last prompt line to
  the end of the pane; only a last line matching the adapter's footer pattern
  (codex: `  <model> · ~/<cwd> · …`) is dropped, so a draft on a second line
  keeps the nudge withheld.
- R2: while the synchronous submit runs, a relay thread finds the job it
  registers (same session, created since the submit) and prints its
  stalled:/stall_watch: notices; before, they appeared only after the job.
- R3: the broker-unavailable disk wait that runs into the deadline is the wall
  clock: wall_clock_timeout (caller rc stays 3).
- R4: a state_change_seq change seen right before the judgement restarts the
  idle window (decision 9: S seconds of continuous idle).

Regression tests are the reviewer's reproductions, each with its passing
twin; the wrapper-level #30 tests now use this environment's broker instead
of the #18 harness's public default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 14:01:53 +00:00
GodopuandClaude Opus 5.5 47e42744e7 docs(issue-30-33): table rows for --job-timeout, MAM_JOB_TIMEOUT_S, wall_clock_timeout and the stall watcher knobs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 11:43:21 +00:00
GodopuandClaude Opus 5.5 622c1f8a9d feat(issue-30): a stall watcher that only notifies
lib_py/stall_watch.py runs beside the wrapper's subscriber for a local herdr
job (started after delivery, stopped when the subscriber ends; MAM_STALL_WATCH=0
turns it off). It follows `herdr agent wait` transitions and publishes only
`progress` events through one publisher; it has no path that closes a job.

- blocked: `stalled:blocked <detection line>` once per episode, never answered.
- idle for MAM_STALL_IDLE_S (600) with no background descendants of the agent
  pid (pane process-info + /proc; adapter resident_children and the heartbeat
  timer excluded) and an empty input box: `stalled:idle` and one nudge.
- input box judged from adapter facts measured on herdr 0.9.1 (codex '›',
  grok frame '│', opencode '┃' block + stop pattern); the whole-pane fallback
  is gone. No facts: withheld + `stall_watch:input_check_unavailable` once;
  never empty for 3 x S: `stall_watch:input_never_empty` once.
- herdr failures are not "no event": warning + `stall_watch:herdr_unavailable:
  <code>` (or agent_not_found), backoff, rc 2 after MAM_STALL_HERDR_FAIL_S
  (120); the wrapper prints the watcher's last lines.
- remote and human jobs are skipped with one log line.
- agent_status_reliable is an adapter field; false turns idle judgement off.
- the loop runner relays stalled:/stall_watch: notices once each.
- scoped_env drops a pane-inherited workspace scope for name lookups (#34 rule).

The module sits at the top of lib_py: delegation and loop may not import the
agent adapters (their sibling-import rules). Idle pane captures of the five
CLIs are fixtures; the mock herdr answers process-info in the real shape.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 11:43:21 +00:00
GodopuandClaude Opus 5.5 ec9586bb9b feat(issue-33): the job budget travels and the two clocks carry different labels
- wrapper: TIMEOUT falls back to MAM_JOB_TIMEOUT_S (--timeout > env > 3600);
  an invalid value warns and keeps 3600.
- loop: --job-timeout <sec> (run_loop.sh passes it on; refused in the legacy
  shell loop). The runner always hands the wrapper --timeout, so the budget
  the wrapper registers is the one its wait caps come from.
- runner: wait_for_job and the MAM_CHILD_WAIT_S default are budget + 300 s
  (MAM_WAIT_GRACE_S), today's 3900 with no option; an explicit
  MAM_CHILD_WAIT_S still wins.
- subscriber: the wall-clock budget returns 4 (RC_WALL_CLOCK), silence keeps
  2 (RC_NO_EVENT). The wrapper maps both to caller rc 2 and labels the close
  from the rc: wall_clock_timeout vs deadline_timeout; the disk-heartbeat
  grace that ends on the deadline is the wall clock too.
- registry close accepts wall_clock_timeout (engine: -> error); the remote
  runner's deadline and command-timeout details lead with wall_clock_timeout;
  the sub-job table files it CLOSED-UNCONFIRMED (first token, colon stripped).

Test expectations changed only where the contract changed: the loop's
contract_args now starts with --timeout, the subscriber's exit-code docstring
names rc 2/4, and the registry --help capture lists the new close kind.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 11:43:10 +00:00
GodopuandClaude Fable 5.1 8ea65bb238 merge: fix/issue-34 (Issue #34: the loop waits for the creator's sub-jobs and hands reviewers their status; v4.7.4)
Reviews: authoring workspace 5 reviewers, full loop round 2 PASS + delta PASS; merge workspace round 1 @ed293dc claude/agy PASS, grok/codex NOT PASS (cancelled sub-job reason lost, remote deadline wording, stray-job rule) -> fixed in 8a9101e + e80186f; round 2 @e80186f all four PASS (identity: base ed293dc -> head e80186f, evidence review-tree). Full suite @ed293dc: 1156 passed, 3 skipped, 2 broker-latency flakes (pass in isolation); targeted suites @e80186f: 125 + 54 passed. Reports under .agents/reports/. Closes #34.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-09-25 07:44:34 +00:00
GodopuandClaude Fable 5.1 22586d2a01 release(v4.7.4): bump framework and eight skills to 4.7.4 in lockstep; archive PR #38 merge-side reviews
Issue #34: the loop waits for the creator job's sub-jobs, reviewers get
the sub-job table as evidence, a run with sub-jobs voids a round whose
source changed during review, and a pane caller reaches an agent of
another workspace on its own server. VERSIONS.md carries the entry; the
merge-side reports (round 1 @ed293dc and round 2 @e80186f with identity
sidecars, base ed293dc -> head e80186f, evidence review-tree) are archived
under .agents/reports/<session>/.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-09-25 16:44:20 +09:00
GodopuandClaude Fable 5.1 e80186f560 fix(issue-34): merge review - the remote runner's deadline wording counts as an unconfirmed close
remote.py closes a job whose end it could not see with
`error --detail "deadline_timeout (remote runner)"`; the sub-job table
matched the reason exactly and filed it as a plain failure, losing the
unconfirmed count and the late-write warning (merge review, codex R2).
The kind is the first word of the reason now. Test T14 replays the
remote runner's close through the real registry and producer; it fails
without the fix.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-09-25 16:23:06 +09:00
GodopuandClaude Fable 5.1 8a9101ed30 fix(issue-34): merge review - a sub-job cancelled before injection keeps its reason in the review table; unattributed open jobs come with a verdict rule
- registry.get_feedback only returned the detail of the last completed or
  error event, so a sub-job the wrapper cancelled before injection
  (target_not_found, session_busy) reached the review table as a bare
  `cancelled` and its reason was lost (merge review, grok N1). get_feedback
  takes the event kinds to read (default unchanged); the table asks for
  cancelled too and shows NOT-DISPATCHED with the reason. Test T13, both
  reasons, fails without the fix.
- the [귀속되지 않은 열린 잡] line listed jobs registered without
  --parent-job but gave the reviewer no rule for them - the one case the
  table cannot show (claude K1). A rule sentence follows the line whenever
  such a job exists; without one the prompt is unchanged. T6 pins it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-09-25 16:19:12 +09:00
GodopuandClaude Opus 5.5 ed293dcef4 fix(issue-34): a pane caller reaches an agent of another workspace on its own server; an unseen target is not recorded as busy
- mam_herdr: for name lookups (agent / has-session / kill-session) a
  HERDR_WORKSPACE_ID that is the caller pane's own workspace (it matches
  the HERDR_PANE_ID prefix) is dropped. Live agent names are unique per
  server (herdr --skill), so this cannot address a same-named agent of
  another workspace; an id the caller chose, or one set outside a pane,
  still scopes (H-18/H-21/H-23). The generated shim is unchanged (S11).
- wrapper: a preflight that cannot see the target closes the job
  cancelled with detail target_not_found instead of session_busy; the
  loop's sub-job table counts it as NOT-DISPATCHED.
- tests: T10-T12 with the measured pane env (same-server socket, caller
  w6, target w2); T8 follows the sub-job-armed guard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 01:25:48 +00:00
GodopuandClaude Opus 5.5 1d4f338b4c fix(issue-34): the stale-review guard is armed only when the run has sub-jobs
Without --worktree/--base the guard fingerprinted the source on every
loop, so a loop whose source holds untracked files that change on every
job voided each round: test_tier4_e2e scenario 5 keeps the mock herdr's
state file (rewritten on each herdr call) in its repository and failed
with rc 1 on 3fdbb62 (passed on 7a4a0c2). A loop without sub-delegation
now behaves as before (issue #34 requirement 3); with sub-jobs the guard
protects against a deadline-closed sub-job still writing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 01:25:48 +00:00
GodopuandClaude Opus 5.5 3fdbb626de fix(issue-34): review - a correction round keeps the earlier creator jobs' sub-jobs in the evidence table
round_roots was replaced by the correction job, so an implementation
job's open or failed sub-job vanished from the next review and the loop
could pass (planner-reviewer-codex-01, P2). The run's creator jobs now
accumulate; each wait still covers only the new job's tree, so the wait
cap is not paid again per round. T9 covers open / error / completed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 14:17:12 +00:00
GodopuandClaude Opus 5.5 8690ef4bd2 docs(issue-34): --parent-job, MAM_CHILD_WAIT_S, --wait-open-jobs, the sub-job verdict rule and the stale-review guard
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 13:37:05 +00:00
GodopuandClaude Opus 5.5 30c8c7ebab fix(issue-34): the loop waits for the creator job's descendants, hands reviewers the sub-job table with closing reasons, and voids a round whose source changed during review in every mode
- wait_for_job_tree at the execution, correction and compliance waits;
  bounded by MAM_CHILD_WAIT_S (3900) and MAM_JOB_TREE_MAX (50)
- [하위 위임 잡] table: OPEN / CLOSED-UNCONFIRMED (deadline_timeout,
  agent_blocked, or error with no reason) / NOT-DISPATCHED / error /
  completed, plus unattributed open jobs; omitted when there are none so
  option-less prompts stay byte-identical
- --wait-open-jobs also waits for unattributed open jobs
- stale-review guard without --worktree/--base: head + tree (control
  plane excluded) before review, re-checked after verdicts and before
  promotion; off with a warning when the source cannot be fingerprinted
- tests/test_issue34_job_tree.py: real wrapper registrations, real
  publish_event / deadline close, real loop process

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 13:37:05 +00:00
GodopuandClaude Opus 5.5 ec663a93c8 feat(issue-34): a job records its parent; submit --parent-job (MAM_JOB_ID by default) and the brief tells the agent to pass it on
- registry: parent_job field, register --parent-job fails closed on an
  unknown parent, children_of/descendants_of, list --parent
- wrapper: --parent-job; $MAM_JOB_ID default (ignored with a warning when
  this registry lacks it); brief line and one sentence at the end of the
  injected instructions
- create_session: the onboarding instructions carry the same sentence

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 13:37:05 +00:00
GodopuandClaude Fable 5.1 7a4a0c2091 merge: fix/issue-36 (Issue #36: delegated prompts travel by file, a failed delegation ends the loop with rc 1; v4.7.3)
Reviews: authoring workspace 5 reviewers x 3 rounds PASS; merge workspace round 1 @59be1c8 claude PASS, agy/grok/codex NOT PASS (S9 macOS wording, runner prompt-file write failure, empty --prompt-file) -> fixed in 708262a; round 2 @708262a all four PASS (identity: base 59be1c8 -> head 708262a, evidence review-tree). Full suite @59be1c8: 1139 passed, 2 skipped, 1 failed (S9, fixed); targeted suites @708262a: 125 passed. Reports under .agents/reports/. Closes #36.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-09-24 09:10:39 +00:00
GodopuandClaude Fable 5.1 c3e6a7e96d release(v4.7.3): bump framework and eight skills to 4.7.3 in lockstep; archive PR #37 merge-side reviews
Issue #36: delegated prompts travel by file, a delegation that fails ends
the loop with rc 1. VERSIONS.md carries the entry; the merge-side reports
(round 1 @59be1c8 and round 2 @708262a with identity sidecars, base
59be1c8 -> head 708262a, evidence review-tree) are archived under
.agents/reports/<session>/.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-09-24 18:10:28 +09:00
GodopuandClaude Fable 5.1 708262a598 fix(issue-36): merge review - a prompt file the runner cannot write is a failed submit; S9 asserts the libc wording on Linux only; an empty prompt file is refused
- runner._run_cmd: the write of the prompt file was outside the cleanup
  and error handling that the exec had (merge review: claude H1, codex R1).
  A failed write now removes the partial file and returns the same 126
  failed submit an unstartable wrapper does, so the loop ends with rc 1
  and no traceback. Test S4c.
- test S9 required the literal "File too large" in stderr; bash 3.2 on
  macOS prints nothing for EFBIG, so the whole suite failed on this host
  (agy, grok, codex R2). The contract asserted everywhere is rc != 0, no
  leftover file and the portable diagnostic line; the libc wording is
  checked on Linux only.
- the wrapper accepted an empty --prompt-file and registered an empty
  prompt (claude H3); it is refused now. Test S1e.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-09-24 17:55:56 +09:00
GodopuandClaude Opus 5.5 59be1c8920 fix(issue-36): create_session hands the agent a brief path, not the task; a prompt file that cannot be written is removed
Review of f26952c (codex, job cc849cbf):

- P2: create_session registered the task by file but then pasted the whole
  task into the onboarding text, which send_keys_safe hands to `herdr agent
  prompt` as one argv string. A 130,500-byte --submit-job was registered
  intact and never reached the agent (execve E2BIG). The task now goes into
  <jobs>/<id>/brief.md, as the delegate-job wrapper does, and the injected
  text names that path. The publish command and its dispatch token (#23) are
  unchanged. The other send_keys_safe callers (the wrapper's short pointer,
  stop_session's exit key) carry no task text.
- P3: delegate_job_safe's write-failure branch left the partial prompt file
  behind and logged to stdout, which callers capture. It now removes the file
  and logs to stderr.

S8 (real create_session.sh, 100 and 130,500 bytes, strace when present) and
S9 (the real function, RLIMIT_FSIZE write failure) carry the reviewer's
reproductions over; all three fail on f26952c.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 07:02:20 +00:00
GodopuandClaude Opus 5.5 f26952ccc3 fix(issue-36): the legacy shell loop and lib.sh's submit helpers pass prompts by file
The two remaining exec points that carried a prompt as one argv string
(capped at 131,072 bytes):

- run_loop.sh (MAM_LOOP_PY=0): delegate_job_safe, which all eleven shell
  delegations go through, turns `--prompt <text>` into `--prompt-file` under
  the control root's .mam/ and removes the file once the wrapper returns. Its
  failure lines go to stderr: callers capture stdout, and under set -e a
  failed submit ends the loop at that line with the wrapper's code, so the
  cause was being swallowed.
- lib.sh delegate_submit_job and its legacy twin (create_session's onboarding
  and --submit-job): the prompt is written under .mam/ and handed on as
  --prompt-file; lib_py.delegation.cli submit accepts --prompt-file and
  forwards it to the registry. --prompt keeps working everywhere.

S6/S6b push a 140,000-byte prompt through the CLI and both helpers; S7 runs
the real run_loop.sh with a 140,000-byte plan and a 150,000-byte diff; S7b
fixes a nonzero exit when the implementation or compliance job cannot be
registered. All six fail on 14d11bf.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 06:08:25 +00:00
GodopuandClaude Opus 5.5 14d11bf062 fix(issue-36): a job that cannot be registered after a rebuttal ends the loop with rc 1
Review of f5cadd9 (planner-reviewer-codex-01, R1): the compliance job's
registration failure was only logged, so a passing next round ended the
real loop process with rc 0. Compliance now returns 1 when it cannot be
registered or does not finish, as the correction step already does.
Re-adjudication and arbitration also return 1 when their job cannot be
registered; a registered job that yields no verdict keeps the designed
fail-closed fallback.

S4c (stubbed delegate_job, a fixture whose later rounds never pass) is
replaced by S3c: a real `python -m lib_py.loop` process whose wrapper runs
an executable that simulates the agents, refusing the compliance,
re-adjudication or arbitration registration while the next round passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 03:38:10 +00:00
GodopuandClaude Opus 5.5 f5cadd98c0 test(issue-36): prompts over the argv cap go through the real wrapper, registry and loop; a failed delegation ends the loop with rc 1 and no traceback
S1-S1d push a 140,000-byte prompt through the real wrapper and registry by
file, with a negative control that the same prompt as an argument fails with
E2BIG. S2 runs the real runner against a recording wrapper. S3/S3b run a real
`python -m lib_py.loop` whose wrapper fails or cannot start. S4/S4b/S4c cover
the exec-error handler, the busy retry and the compliance log. S5 sends a
collected diff from the former 131,072..200,000-byte blind spot through whole.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 02:30:13 +00:00
GodopuandClaude Opus 5.5 c77806b1b1 fix(issue-36): the loop passes every delegated prompt as a file; a wrapper that cannot start is a failed submit, on the first try and on the retry
delegate_job writes the prompt to a temp file under the control root's .mam/
and hands the wrapper --prompt-file; the file is removed once the wrapper
returns. An OSError from starting the wrapper becomes a failed submit (rc 126)
that the existing error branch logs, so the session-busy retry no longer
dereferences a missing result. A retry that yields no job id, and a
compliance job that cannot be registered, are now logged instead of silent.

The collector caps are documented as the size of the evidence a reviewer is
shown; delivery has no cap of its own any more. The loop skill notes that a
pipeline hides the loop's exit code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 02:30:13 +00:00
GodopuandClaude Opus 5.5 68a5d31381 fix(issue-36): registry and wrapper accept --prompt-file; the wrapper hands the same path on
One argv string is capped at MAX_ARG_STRLEN (131,072 bytes on Linux), so a
prompt passed as --prompt could not reach the registry once it carried a
large diff. submit now takes --prompt-file and passes the same path to
`registry.py register --prompt-file`; the loop type's per-iteration update
goes by file too. --prompt keeps working unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 02:30:13 +00:00
GodopuandClaude Fable 5.1 1debc7d924 merge: fix/issue-23-24 (Issues #23, #24: cross-server herdr scope, agent-agnostic start-up dialogs, started-based onboarding verdict, verified rollback; v4.7.2)
Reviews: authoring workspace 5 reviewers x 5 rounds PASS; merge workspace round 1 @f4ba205 claude/agy/grok PASS (codex report lost to host sleep; its two probes fixed in 05b0ea4), round 2 @05b0ea4 all four PASS (identity: base f4ba205 -> head 05b0ea4, evidence review-tree). Full suite @f4ba205: 1111 passed, 1 skipped; affected suites @05b0ea4: 123 passed. Reports under .agents/reports/. Closes #23, #24.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-09-23 23:36:37 +00:00
GodopuandClaude Fable 5.1 9fb037c084 release(v4.7.2): bump framework and eight skills to 4.7.2 in lockstep; archive PR #35 merge-side reviews
Issues #23 and #24: cross-server herdr scope, agent-agnostic start-up
dialogs, onboarding delivery judged by the agent's own started event,
verified rollback. VERSIONS.md carries the entry; the merge-side reports
(round 1: claude/agy/grok; round 2 on 05b0ea4: all four, with identity
sidecars, base f4ba205 -> head 05b0ea4, evidence review-tree) are archived
under .agents/reports/<session>/. The round-1 codex report was lost to the
host sleeping mid-review; its two probes are recorded in the changelog.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-09-24 08:36:35 +09:00
GodopuandClaude Fable 5.1 05b0ea4762 fix(issue-23-24): merge review - the environment form of the auto-trust switch reaches the handler; a rate-limited dialog does not hide the next one
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
2026-09-24 06:47:57 +09:00
GodopuandClaude Fable 5.1 f4ba205c9f fix(issue-23): the remote audit record names the dispatch it vouches for; a record without identity fails closed
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>
2026-09-22 17:52:51 +00:00
GodopuandClaude Fable 5.1 087eacaf21 fix(issue-23): both ack waits share one contract - the wrapper's status bypass is gone; the G9 guard removes only the docstring node
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>
2026-09-22 16:43:00 +00:00
GodopuandClaude Fable 5.1 4805fd4511 test(issue-5): the G9 no-frozen-helper guard reads code, not comments or docstrings
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>
2026-09-22 15:32:40 +00:00
GodopuandClaude Fable 5.1 1667fda8dd fix(issue-23): the onboarding ack is satisfied by what a real agent publishes; one command builder and one started predicate for create and the wrapper
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>
2026-09-22 14:42:38 +00:00
GodopuandClaude Fable 5.1 f7cee00dec fix(issue-23): the caller's server comes from HERDR_SOCKET_PATH, not only HERDR_SESSION
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>
2026-09-22 14:13:38 +00:00
GodopuandClaude Fable 5.1 10a678c010 fix(issue-23-24): the dialog handler sees the ready banner on its own; only this dispatch's started is delivery evidence
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>
2026-09-22 10:25:52 +00:00
GodopuandClaude Fable 5.1 0dfbb2cc8a fix(issue-24): resume answers start-up dialogs for every agent, with the same switch as create
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>
2026-09-22 08:19:18 +00:00
GodopuandClaude Opus 5 ce802d21c8 docs(issue-23): the create skill documents the preserve-on-unacknowledged-onboarding rule and MAM_ONBOARD_ACK_S
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 06:24:33 +00:00
GodopuandClaude Opus 5 33b91fe0c8 fix(issue-23): an unacknowledged onboarding marks the job and keeps the session
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>
2026-09-22 06:24:06 +00:00
GodopuandClaude Opus 5 2cd20423ac docs(issue-23-24): the create skill documents --no-auto-trust, the started-based onboarding verdict and exit 43
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 04:28:57 +00:00
GodopuandClaude Opus 5 123dca47bb fix(issue-23): onboarding delivery is judged by the agent's own started event, and a rollback verifies the kill
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>
2026-09-22 04:28:27 +00:00
GodopuandClaude Opus 5 5ba04a27ca feat(issue-24): startup dialogs come from the agent adapter and are handled while waiting for the TUI
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>
2026-09-22 04:22:09 +00:00
GodopuandClaude Opus 5 3bcaa39ab0 fix(issue-23): a herdr call to another server drops the caller pane's workspace/pane/tab ids
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>
2026-09-22 04:14:45 +00:00
GodopuandClaude Fable 5.1 1e78dae6eb merge: fix/issue-25 (Issue #25: promoted plans are reused only with --plan-file; empty plan reports are not silent; v4.7.1)
Reviews: authoring workspace round 2 @1e82651 5/5 PASS; merge workspace @1e82651 claude/agy/grok/codex 4/4 PASS (identity: base 6fc9194 -> head 1e82651, evidence review-tree). Full suite @1e82651: 1037 passed, 1 skipped. Reports under .agents/reports/. Closes #25.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-09-21 15:38:21 +00:00
GodopuandClaude Fable 5.1 6b7198f73c release(v4.7.1): bump framework and eight skills to 4.7.1 in lockstep; archive PR #32 merge-side reviews
Issue #25: a promoted plan is reused only with --plan-file; the implicit
newest-plan load is gone in both runners, and an empty planner report no
longer passes silently. VERSIONS.md carries the entry; the four merge-side
review reports and their identity sidecars (base 6fc9194 -> head 1e82651,
evidence review-tree) are archived under .agents/reports/<session>/.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-09-22 00:38:10 +09:00
GodopuandClaude Opus 5 1e826515cc fix(issue-25): a relative --plan-file is taken against the control root, as its usage line says
Review R1/N1 (reviewer-grok-01, planner-reviewer-codex-01), reproduced before
fixing. The usage line and SKILL.md both promise "relative to the control
root", but parse_args did Path(...).expanduser().resolve(), which is relative
to the caller's cwd. The runner never chdirs to workspace_root, so the two are
routinely different - the documented recipe runs the loop from the control root
while the source root is a worktree.

Two ways it went wrong: a plan that exists only under the control root was not
found at all (exit 1), and a same-named file in the caller's cwd was loaded
instead, quietly putting an unrelated plan into the first implementation
prompt. That second one is the very failure #25 is about, reintroduced through
the option meant to replace it.

Resolve against workspace_root the way jobs_dir already does (Issue #21 R2);
pathlib's `/` leaves an absolute or expanded-home path untouched. Every earlier
test passed an absolute tmp_path, which is why none of them caught this: P3c
covers both failure modes with the control root away from the caller's cwd, and
P3d pins that absolute and ~ paths are not rewritten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 05:17:50 +00:00
GodopuandClaude Opus 5 5fa592347c docs(issue-25): the loop skill documents --plan-file and the empty-report handling
The skill described the implicit load as a feature in five places. Replace them
with what the loop does now: nothing is loaded without --plan-file, the path is
relative to the control root, and the plan rides the first implementation
prompt only. Adds the CLI table row and a section on what an empty plan report
means at each of the four load points.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 03:26:44 +00:00
GodopuandClaude Opus 5 a334083760 test(issue-25): plan reuse is explicit only and empty plan reports are not silent (P1-P11)
P1 is the reproduction: a running planner plus a promoted plan in its report
directory must not reach the Creator. P2 pins that a named plan is used, that
the newer unrelated one is not, and that the plan rides only the first
implementation prompt - the correction round carries feedback alone, as the
implicit load did too. P3-P5 keep the option's checks in front of any job.
P6 keeps --plan mode and its promotion working.

P9-P11 cover the loads the review widened: an empty initial plan stops the run,
an empty discussion refine keeps the previous plan, and an empty escalation
refine leaves the "follow the updated plan below" prompt carrying the real one.
The existing _drive helpers hand every job the same non-empty PASS report and
so cannot reach these paths at all, so this module drives the loop with its own
stub that scripts a report per job. P7-P8 pin wrapper/runner parity, the legacy
shell refusal, and that neither implementation globs for a plan any more.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 03:26:44 +00:00
GodopuandClaude Opus 5 e551e3baf1 fix(issue-25): one plan-text check for every load; an empty planner report stops the loop, an empty refine keeps the plan
The --plan-file check landed on the one path a human is watching. The three
--plan-mode loads read a report written by another agent with nobody watching,
and had no content check at all: find_report only tests that the path exists.

An empty or whitespace-only report therefore blanked current_plan in silence.
Phase 2's `if self.current_plan` then flipped the prompt to self-planning and
the run finished rc 0, having paid for a planner job, a Creator challenge and a
refine job and thrown the result away. The escalation refine was worse: it has
no guard, so the correction prompt asserted "follow the updated plan below" and
carried nothing.

All four loads share _plan_text() now - a plan is a readable regular file with
text in it - and each caller says what None means. An empty initial plan stops
the run instead of demoting it, and an empty refine warns and keeps the plan it
already has, which is still good. Those two together leave the escalation
prompt unable to claim a plan it does not carry, so it needs no new branch.

Found by the Creator's challenge to the plan (job 0f63ac1d), sustained by the
Planner as revision 1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 03:26:34 +00:00
GodopuandClaude Opus 5 a28711cccf fix(issue-25): reuse a promoted plan only with --plan-file; drop the implicit newest-plan load
Without --plan, Phase 1 resolved a planner session automatically and globbed
<workspace_root>/.agents/reports/<planner>/plan-*.md for the newest file by
mtime, handing it to the Creator as "the plan already agreed on". Both keys are
wrong: the report directory belongs to a session, not to this task, so other
issues' plans pile up in it, and right after a clone or checkout mtime is
checkout order. 47 such plans are committed in this repository, so an ordinary
run could silently implement against an unrelated design.

Reuse is explicit now. --plan-file <path> is the only way to load an existing
plan; it is resolved against the control root, checked before a single job
exists, and refused if the file is missing, not a regular file, unreadable or
empty (an empty file would quietly turn into a "no plan" prompt). It cannot be
combined with --plan, and --review-only warns and ignores it because no
implementation job is delegated there. The implementation prompt itself is
unchanged, so the Creator's contract does not move.

Phase 1 now says which plan it loaded and where from, or that it loaded none.
The legacy shell loop (MAM_LOOP_PY=0) loses the same auto-load and rejects
--plan-file rather than duplicating the check on a path that is on its way out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 03:26:24 +00:00
GodopuandClaude Fable 5.1 6fc9194f89 merge: feat/issue-21-items-4-5-7 (Issue #21 phase 2: worktree helpers, review identity, acceptance A1-A8; #20 test isolation; #27; #28 host portability; v4.7.0)
Reviews: round 1 @46ea4cc claude/agy/grok PASS, codex NOT PASS (R1-R3) -> fixed in 08db1da; delta review @08db1da claude/agy/grok PASS, codex @80a5614 PASS. Reports archived under .agents/reports/.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-09-20 21:43:39 +09:00
449 changed files with 42574 additions and 2232 deletions

No files matched your search

+1
View File
@@ -151,6 +151,7 @@ TMUX 환경에서 실행되는 에이전트가 화면 스크롤 한계로 인해
- **작업 위임**:
- *수동 경로*: 상세 사양과 계획 수립 등의 복잡한 작업 지시는 먼저 로컬 마크다운 파일(예: `.mam/reports/brief-<job_id>.md` 또는 지정된 워크스페이스 경로)로 작성한 후, 에이전트에게 `"Read <파일경로> and execute."` 라는 실행 명령만 전달하십시오.
- *자동 경로*: 자동화 잡 런너(`multi-agent-mux-delegate-job submit`)는 잡 등록 시 `.mam/jobs/<job_id>/brief.md` 디렉터리에 지시서를 자동 집필하고 단일 라인 포인터 프롬프트만 에이전트 세션에 인가합니다.
- *하위 위임*: 자기 잡의 일부를 다시 위임하는 에이전트는 `submit` 에 `--parent-job <자기 job_id>` 를 붙입니다. 그래야 루프가 그 하위 잡을 기다리고 리뷰어에게 보여 줍니다(Issue #34).
- **결과 안내 및 피드백**:
- *수동/영구 리뷰*: 상세 리뷰 결과, 설계 제안서 등은 `.mam/reports/<tmux_session_name>/report-<job_id>.md` 경로에 저장합니다.
- *자동화 잡 보고서*: 자동 위임된 비동기 작업의 완료 결과는 잡 디렉터리 하위인 `.mam/jobs/<job_id>/<agent_name>-reports/report-final.md` (루프/Discuss 위임 시에는 `<clean_session_name>-reports/`) 경로에 기록해야 합니다.
+1
View File
@@ -151,6 +151,7 @@ To ensure that agents running in TMUX environments do not lose debug logs or pre
- **Task Delegation**:
- *Manual path*: Detailed task briefs may be written to a local Markdown file (e.g., `.mam/reports/brief-<job_id>.md` or a workspace path) first. The sender then issues a simple trigger command: `"Read <file_path> and execute."`
- *Automated path*: The automated job runner (`multi-agent-mux-delegate-job submit`) automatically provisions the brief at `.mam/jobs/<job_id>/brief.md` and sends a short pointer instruction to the agent.
- *Sub-delegation*: an agent that delegates part of its job passes `--parent-job <its job_id>` to `submit`, so the loop waits for the sub-job and shows it to reviewers (Issue #34).
- **Result Reporting & Feedback**:
- *Manual/Durable reviews*: Detailed reviews, design proposals, or audit reports must be saved under `.mam/reports/<tmux_session_name>/report-<job_id>.md`.
- *Automated job reports*: Automated execution results are saved directly to `.mam/jobs/<job_id>/<agent_name>-reports/report-final.md` (or `<clean_session_name>-reports/` for loops) as transient files.
@@ -0,0 +1,35 @@
# 🛠️ Creator 수정 리포트 — codex R1(F2 실패 캐시) 반영 (job `5b2974f5`)
- **Author**: `creator-claude-01` (role: creator) · 2026-10-02
- **작업 트리**: `/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux-wt/followups` · `fix/followups-41-43` · `95eabc6` → **`75f364e`** (커밋 1개 추가, 미커밋 변경 없음, ignored 는 `.venv/` 뿐)
- **범위**: R1 수정 + 경미한 문서 권고 1건. push·merge·PR·버전 bump 없음. 실제 `.mam`(이 리포트 제외)·`.mam.env`(export 안 함)·세션·herdr·모니터 무조작.
## 1. 판단
R1 은 타당하다 — 반론 없음. `ensure_env_loaded()` 가 `_load_dotenv()` 전에 `_env_loaded = True` 를 세웠고, F2 의 `load_env_or_report()` 가 예외를 반환값으로 바꾸면서 같은 프로세스의 두 번째 호출이 깨진 파일을 건너뛰게 됐다. 리뷰어가 권한 방향(성공 뒤에만 캐시)을 그대로 택했다.
## 2. 변경 (`75f364e`)
- `.agents/skills/lib_py/delegation/mqtt_common.py` `ensure_env_loaded`: `_load_dotenv(workspace_dir)` **뒤에** `_env_loaded = True`. 실패는 다음 호출에서 다시 읽고, 성공은 전처럼 프로세스당 한 번. `envfile.apply` 는 파일 전체를 파싱한 뒤에 대입하므로 실패한 로드는 환경을 하나도 바꾸지 않는다(부분 적용 없음) — 재시도가 안전하다. 라이브러리 직접 호출(`ensure_env_loaded`)도 같은 규칙이 된다.
- `MESSAGING.md:369`(경미 권고): ERROR **한 줄**은 Python 진입점의 것이고, 래퍼는 그 줄 뒤에 `ERROR: cannot load <path> (see the line above)` 를 한 줄 더 찍는다고 정정.
- `tests/test_issue26_envfile.py::test_a1_a_failed_load_is_not_cached_as_loaded` — 한 프로세스에서 실제 `registry.main(['--registry-dir', <jobs>, 'register', '--prompt', …])` 를 깨진 UTF-8 파일로 두 번 호출하고 파일을 복구·변경하며 확인:
| 리뷰어 요구 | 단언 |
|---|---|
| (a) 연속 실패 두 번 모두 rc 1 | `rc == [1, 1]`, ERROR 줄이 두 번 |
| (d) 실패 중 등록 없음 | `registered == []` |
| (b) 복구 후 설정 적용 | `load_env_or_report()` → `[True, "from-file"]` |
| (c) 성공 후 once-per-process 유지 | 파일을 바꿔도 `[True, "from-file"]` |
## 3. 역검증
`git worktree add --detach <scratch>/head 95eabc6` 에 새 테스트 파일만 복사해 실행 → **실패**: `{'rc': [1, 0], 'registered': ['421e9769.json'], 'recovered': [True, None], 'cached': [True, None]}` (`assert [1, 0] == [1, 1]`). 리뷰어 탐침과 같은 결과다. 임시 워크트리는 제거했다(남은 워크트리: 본 체크아웃, followups, 그리고 내가 만들지 않은 `multi-agent-mux-wt/fu-suite` — 손대지 않음).
## 4. 테스트 (수정 후, 브리프 명령 형식, `.mam.env` export 없음)
`tests/test_issue26_envfile.py tests/test_issue5_phase1_delegation.py tests/test_tier1_unit.py tests/test_version_consistency.py tests/test_issue18_liveness_contract.py` → **205 passed in 178.52s** (envfile 38 · issue5 66 · tier1 67 · version 3 · issue18 31). issue5 의 `ensure_env_loaded` 멱등 단언(`:506-508`)도 통과. 바뀐 것은 env 로딩 캐시 한 곳뿐이라 나머지(F1·F3~F6) 파일은 다시 돌리지 않았다 — 직전 실행(`1e92ddc`/`95eabc6`) 결과와 리뷰어의 303 passed·52 passed 가 유효하다.
## 5. 위생
- `test_version_consistency` 가 기존부터 작업 트리에 `.mam/shim/herdr` 를 쓴다(직전 리포트에 보고한 base 동작). 내용이 그 심 하나뿐임을 확인하고 지웠다.
- scratchpad 경로를 가진 남은 프로세스 0개.
@@ -0,0 +1,140 @@
# 🛠️ Creator 리포트 — PR #41·#43 후속 F1~F6 (job `aa7f2b5f`)
- **Author**: `creator-claude-01` (role: creator) · 2026-10-02
- **작업 트리**: `/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux-wt/followups` · 브랜치 `fix/followups-41-43` · base `0051fd6` → head **`95eabc6`** (커밋 8개, 미커밋 변경 없음, ignored 는 `.venv/` 뿐)
- **범위 준수**: 본 체크아웃(`main @ 0051fd6`)은 읽기만 했다. push·merge·PR·이슈 변경 없음, 버전 bump 없음(4.8.1 그대로). #17 항목 4·#39·#40·#42 범위는 건드리지 않았다. 실제 `.mam`(이 리포트 파일 제외)·`.mam.env`(읽지도 출력하지도 않음)·모니터·에이전트 세션·herdr 서버는 손대지 않았고 `.mam.env` 는 export 하지 않았다(테스트는 conftest 의 로컬 브로커). basetemp·JUnit·로그·역검증용 임시 워크트리는 모두 작업 트리 밖 scratchpad 에 두었고 임시 워크트리는 제거했다.
## 1. 커밋
| 커밋 | 항목 | 요지 |
|---|---|---|
| `75936f1` | F1 | A8 이 마커 lstart 를 `loop_lock.sh` `mam_lstart` 와 같은 정규화(공백 압축·양끝 trim)로 기록. 두 칸 공백 `ps` 심 변형으로 날짜와 무관하게 고정. 제품 코드 무변경 |
| `5edf913` | F2 | `mqtt_common.load_env_or_report()` — publish_event·registry·job_subscriber·cli·bench·heartbeat·remote 의 main 이 `ERROR: <경로>: cannot read (<예외 종류>)` 한 줄 + rc 1. `ensure_env_loaded()` 는 여전히 예외(라이브러리는 exit 하지 않음) |
| `95eabc6` | F2 보정 | job_subscriber 는 종료 코드를 이름으로 돌려준다(`test_issue5` 스냅숏): `return RC_ERROR`(값 1 그대로) |
| `71ea809` | F3 | (a) 공통 파서를 `utf-8-sig` 로(BOM 제거, 두 로더 공통) (b) 중복 키 첫 값 우선을 `.mam.env.example` FORMAT·`envfile.py` 독스트링에 명시(동작 그대로) (c) `MESSAGING.md:369` 를 실제 동작(없으면 조용히 건너뜀·주변 탐색 안 함, 있는데 못 읽으면 `EnvFileError` → ERROR 한 줄 rc 1)으로 |
| `8b04639` | F4a·F4c | 러너 `_int_knob` = `_positive_int` 규칙 + 잘못된 값 경고 1회 후 기본값. loop SKILL.md 예산 행·하위 잡 문단에 `MAM_WAIT_GRACE_S` 와 정수 규칙 |
| `fb1b93c` | F4b | bash 노브 8개를 `mam_int_knob` 으로. `mam_int`/`mam_int_knob` 를 `lib_int.sh` 로 **그대로 이동**(lib.sh 가 source, lib.sh 없이 도는 `loop_lock.sh`·`diff_collect.sh` 는 함수가 없을 때 직접 source) |
| `c252ac4` | F5 | `stall_watch.py` 의 `MAM_TEST_UNRELIABLE_AGENT` 분기 삭제, S13 은 어댑터 seam 으로 |
| `1e92ddc` | F6 | delegate-job SKILL.md `<= 60 s` 를 herdr wait 에 한정, "대기 중 래퍼 소멸" 테스트를 두 번째 wait 안에서 소멸하도록 |
## 2. 항목별 내용과 완료 조건
### F1 — 날짜 의존 A8
- 원인: `ps -o lstart=` 는 한 자리 날짜를 `Oct 2` 로 두 칸 패딩한다. 제품 `mam_lstart` 는 `tr -s ' '` 로 줄여 비교하는데 A8 은 `.strip()` 만 한 원값을 기록 → 살아 있는 holder 가 stale 로 보여 두 번째 루프가 거부되지 않음(매달 1~9일).
- 수정(`tests/test_issue21_acceptance.py`): `_marker_lstart()` 가 `" ".join(out.split())`(= `tr -s ' '` + trim)로 기록. `@pytest.mark.parametrize("ps", ["real", "padded"])` — `padded` 는 PATH 앞의 `ps` 심이 lstart 요청에 항상 `Thu Oct 1 08:02:03 2026` 을 돌려주므로 **날짜와 무관하게** 두 칸 공백 경로를 지난다(심이 실제로 쓰였는지 `Thu Oct 1 …` 로 확인).
- 같은 패턴 조사: 살아 있는 lstart 를 마커에 쓰는 테스트는 `test_o3_scoped_guard._get_lstart`(이미 `" ".join(split())`)뿐. `test_o2_race_free_lock` 의 마커는 고정된 stale 값이고, 그 파일의 `get_lstart()` 는 **정의만 있고 쓰이지 않는다**(죽은 코드 — 손대지 않음). 제품 쪽 다른 소비자: `loop_delegation_guard.sh` 는 양쪽을 split-join, `lib.sh` 의 `ps -o pid=,lstart=` 는 `strptime` 이라 두 칸에 무관.
### F2 — Python 진입점의 EnvFileError
- `mqtt_common.load_env_or_report() -> bool`: `ensure_env_loaded()` 를 부르고 `EnvFileError` 면 `print(f"ERROR: {e}", file=sys.stderr)` 후 False. 각 main 은 `if not …: return 1`(job_subscriber 는 `RC_ERROR`). 메시지는 `envfile` 의 `"{path}: cannot read ({type})"` 그대로라 값·내용이 들어가지 않는다.
- 범위: 브리프가 든 7개 main. **reconcile.sh 의 임베디드 구독자**도 `ensure_env_loaded()` 를 부르지만 main 이 아니라 목록 밖이라 그대로 두었다(트레이스백 유지) — 필요하면 후속.
- 리뷰어 참고: job_subscriber 의 rc 1 은 원래 "감시한 잡이 error 로 끝남"(`RC_ERROR`)과 같은 값이다. 수정 전에도 트레이스백으로 rc 1 이었으므로 값은 바뀌지 않았고, 래퍼는 같은 파일을 먼저 읽고 스스로 rc 1 로 끝나 이 구독자를 띄우지 않는다. 별도 코드(예: 3 fatal)로 바꾸는 것은 계약 변경이라 하지 않았다.
### F3 — .mam.env 세부
- (a) `envfile.parse` 가 `encoding="utf-8-sig"` — 파일 맨 앞 BOM 만 벗기며, 래퍼(`envfile.py --shell`)와 `mqtt_common` 이 같은 함수를 쓰므로 두 로더 공통.
- (b) `apply()` 가 이미 있는 키를 건너뛰므로 첫 값이 이긴다(동작 무변경) — FORMAT 절에 `a key given twice keeps its FIRST value` 와 BOM 규칙 추가.
- (c) MESSAGING 2번 항목을 실제로: 명시 파일이 없으면 조용히 건너뛰고 주변 탐색을 하지 않는다(환경변수와 기본값으로 동작), 있는데 못 읽으면 `EnvFileError` → ERROR 한 줄 rc 1. 기존 문장의 "error is logged"·"fail-closed (`RuntimeError`)" 는 코드에 근거가 없어 지웠다.
- 참고(범위 밖, 손대지 않음): 같은 블록 3번 항목의 탐색 순서(`MAM_REAL_ROOT`·`WORKSPACE_ROOT`·cwd)는 `mqtt_common._load_dotenv`(모듈 위치에서 위로 `.agents`/`.git` 탐색)와 다르다.
### F4 — 정수 노브
- (a) `runner._int_knob(name, minimum)` → None(미설정·잘못된 값, 후자는 노브당 경고 1회) 이면 호출부 기본값. 최솟값과 근거(`tests/test_issue26_ints.py` `RUNNER_INT_KNOBS`):
| 노브 | 최솟값 | 기본 | 근거 |
|---|---|---|---|
| `MAM_WAIT_GRACE_S` | 0 | 300 | 0 = 예산만큼만 대기 |
| `MAM_CHILD_WAIT_S` | 0 | 예산 + grace | 0 = 하위 잡을 한 번만 확인(test_issue34 가 0 사용) |
| `MAM_JOB_TREE_MAX` | 1 | 50 | 0 은 모든 트리를 "over" 로 만든다; 기다리지 않기는 `MAM_CHILD_WAIT_S=0` |
동작 변화: `MAM_CHILD_WAIT_S` 는 전에 `float()` 이라 `0.5`·`1e3` 을 받았고 `abc` 는 ValueError, `MAM_JOB_TREE_MAX` 는 `abc`·빈 값이 ValueError, `MAM_WAIT_GRACE_S` 는 `" 30 "` 도 조용히 300 이었다 → 이제 모두 10진 정수 규칙(경고 후 기본값).
- (b) bash 노브(`INT_KNOBS` 에 추가, `NOT_ARITHMETIC` 에서 제거):
| 노브 | 위치 | 최솟값 | 기본 | 근거 |
|---|---|---|---|---|
| `MAM_READY_EMPTY_GIVEUP` | lib.sh `wait_for_tui_ready` | 1 | 5 | 0 은 1 과 같게 동작 |
| `SKS_EMPTY_GIVEUP` | lib.sh `_pane_quiescent` | 1 | 3 | 같음 |
| `SKS_IDLE_RETRIES` | lib.sh `wait_for_agent_idle` | 0 | 2 | 0 = 재시도 없음 |
| `SKS_IDLE_RETRY_BACKOFF_S` | 같음(sleep) | 0 | 10 | test_issue6 가 0 사용; 소수 사용처 없음 |
| `MAM_LOCK_STEAL_RETRIES` | loop_lock.sh | 0 | 3 | 0 = 한 번 시도 |
| `MAM_DIFF_MAX_BYTES`/`_LINES` | diff_collect.sh | 1 | 200000/4000 | 0 은 모든 diff 를 자름 |
| `RECONCILE_POLL_INTERVAL` | reconcile.sh(sleep) | 1 | 15 | 0 은 바쁜 루프; 소수 사용처 없음 |
`SKS_QUIESCENT_INTERVAL` 은 **기본값이 0.5 초**라 소수가 실제 의미 → 그대로 두고 `NOT_ARITHMETIC` 에 "sleep, fractional by default (0.5) - left raw" 로 사유를 남겼다.
- `lib_int.sh` 를 만든 이유: `diff_collect.sh` 는 Python 러너가 lib.sh 없이 `source` 하고(`runner.collect_changes_diff`), `loop_lock.sh` 도 테스트가 단독 source 한다. lib.sh 를 source 하면 herdr 심 설치 등 부작용이 있어 함수만 분리했다. 함수 본문은 한 글자도 바꾸지 않았다. 연쇄 변경: `deploy/remove.sh` 의 매니페스트 없는 폴백 목록에 `lib_int.sh` 추가(설치는 `.agents/skills` 의 모든 파일을 복사하므로 그대로 배포됨), `test_issue21_loop_b::l15` 가 가짜 제어 루트에 `diff_collect.sh` 만 두던 것을 `lib_int.sh` 도 함께 두게.
- (c) loop SKILL.md: 예산 행에 `MAM_WAIT_GRACE_S`(기본 300, 0 이상, 잘못된 값 경고 후 300, 덮는 꼬리 설명), 하위 잡 문단의 `MAM_CHILD_WAIT_S` 기본을 "예산 + `MAM_WAIT_GRACE_S`", 0 의 의미, `MAM_JOB_TREE_MAX` 1 이상, 정수 규칙.
### F5 — 제품 코드의 테스트 훅
- `StallWatch.__init__` 의 `MAM_TEST_UNRELIABLE_AGENT` 두 줄 삭제(이제 `.agents/skills` 전체에 `MAM_TEST_` 없음).
- S13: `_watch(..., unreliable=True)` 가 `python -c _UNRELIABLE_MAIN claude <args>` 로 실제 `stall_watch.main()` 을 실행하되, 그 프로세스에서 `type(get_adapter("claude")).agent_status_reliable = property(lambda self: False)`. 검증 내용은 그대로(5 초 동안 idle 알림·nudge 없음 → blocked 알림 `stalled:blocked Do you want to proceed?` → 로그 `idle judgement off for claude`).
### F6 — 문서 정밀도와 "대기 중 래퍼 소멸"
- SKILL.md: "한 herdr wait(<= 60 s) — 또는 알림 발행 중에 래퍼가 사라지면 그 발행이 끝날 때까지" 로 한정.
- 테스트: `_during_notice` 에 `in_wait(tick)` 훅(각 가짜 herdr wait 안에서 호출). 래퍼는 **두 번째 wait 안에서** 사라지고, 테스트는 `ticks == [1, 2]`(두 번째 wait 에 실제로 들어감)와 그때까지 alive 가 참이었음을 확인한 뒤 알림·키 0 을 단언한다.
## 3. 회귀 테스트가 수정 전 코드에서 실패함을 확인한 방법
`git worktree add --detach <scratch>/base 0051fd6`(작업 트리 밖)에 새 테스트 파일을 복사해 돌리고, 확인 후 `git checkout` 으로 되돌렸다. 마지막에 `git worktree remove --force` 로 제거(남은 워크트리 2개: 본 체크아웃·followups).
| 항목 | base(0051fd6)에서 | 수정 후 |
|---|---|---|
| F1 | 원래 A8: **실패**(오늘 10월 2일, `already running` 없음). 새 테스트에서 `_marker_lstart` 를 옛 `.strip()` 으로 되돌리면 `real`·`padded` **둘 다 실패** — `padded` 는 날짜와 무관 | 2 passed |
| F2 | 14 케이스(7 진입점 × 000 권한·UTF-8 깨짐) **14 실패**(트레이스백) | 14 passed |
| F3 | A7 3개 **3 실패**: BOM 이 `FIRST` 를 버림 / FORMAT 문구 없음 / MESSAGING 의 옛 문장 | 3 passed |
| F4a | 새 케이스 중 **15 실패**: grace 의 잘못된 값 6(경고 없이 300, `" 30 "` 은 300), child-wait `abc`·`-5`·`0.5`·`9999999999` 4(ValueError 또는 받아들임), tree-max `0`·`abc`·`-1`·빈 값 4(ValueError 또는 0 수용), 정적 검사 1. 통과한 14 는 결과가 같은 유효 값 | 44 passed |
| F4b | **14 실패**: 표 정적 검사 2, lib.sh 없이 source 한 스크립트 11(`abc`·`0900`·`" 7 "`·`-1`·`1.5`, diff 는 `0` 도), `_pane_quiescent` + `SKS_EMPTY_GIVEUP=abc`(`[` 오류) 1. 통과 3 은 유효 값 | 통과 |
| F5 | 정적 검사(`MAM_TEST_` 없음) **실패**. 추가로 수정 후 트리에서 seam 을 빼면(`unreliable=True` 제거) S13 이 **실패** → seam 이 unreliable 상황을 만든다는 증거 | 2 passed |
| F6 | 테스트만 바뀐 항목이라 두 가지로 확인: (1) 새 테스트에 옛 호출 횟수 대역을 넣으면 `assert [1] == [1, 2]` 로 **실패** — 옛 테스트는 두 번째 wait 에 들어가지도 않고 루프 머리에서 끝났다. (2) 제품의 wait 직후 `_wrapper_gone()` 검사를 지운 변이체에서 새 테스트가 `notes == ['stalled:idle']` 로 **실패**(수정 안 한 제품에서는 통과) | 1 passed |
## 4. 테스트 결과
명령(브리프 그대로, `.mam.env` export 없음):
`AGENT_PYTHON_BIN=<wt>/.venv/bin/python PYTHONDONTWRITEBYTECODE=1 <wt>/.venv/bin/python -m pytest -p no:cacheprovider -q --basetemp=<scratch>/bt-final --junitxml=<scratch>/final.xml <파일들>`
최종 실행 @ `1e92ddc` — **605 passed, 1 failed, 2 skipped (885 s)**. 실패 1건은 내 F2 회귀였다(`test_issue5_phase1_delegation::test_job_subscriber_cli_contract_snapshot` — job_subscriber 의 맨 숫자 `return 1`). `95eabc6` 로 고친 뒤 `test_issue5_phase1_delegation.py` + `test_issue26_envfile.py` 재실행 **103 passed**. `95eabc6` 의 변경은 같은 값(1)을 이름(`RC_ERROR`)으로 바꾼 것뿐이고 그 분기는 env 파일 실패에서만 탄다.
| 파일 | 통과 | 실패 | 스킵 |
|---|---|---|---|
| test_issue26_envfile.py | 37 | 0 | 0 |
| test_issue26_ints.py | 93 | 0 | 0 |
| test_issue21_acceptance.py | 10 | 0 | 0 |
| test_issue30_stall_watch.py | 48 | 0 | 1 (`/proc` 전용) |
| test_issue30_stall_watch_stop.py | 11 | 0 | 0 |
| test_tier1_unit.py | 67 | 0 | 0 |
| test_version_consistency.py | 3 | 0 | 0 |
| test_issue33_budget.py | 20 | 0 | 0 |
| test_issue34_job_tree.py | 20 | 0 | 0 |
| test_issue21_loop_b.py | 34 | 0 | 0 |
| test_issue5_phase1_delegation.py | 65 → **재실행 66** | 1 → **0** | 0 |
| test_issue18_liveness_contract.py | 31 | 0 | 0 |
| test_o2_race_free_lock.py | 22 | 0 | 0 |
| test_o3_scoped_guard.py | 32 | 0 | 0 |
| test_b7_diff_untracked.py | 20 | 0 | 0 |
| test_issue36_prompt_transport.py | 23 | 0 | 1 (Linux `MAX_ARG_STRLEN`) |
| test_issue6_turn_handshake.py | 14 | 0 | 0 |
| test_c1_tui_readiness.py | 23 | 0 | 0 |
| test_b19_headless_reconcile_fixes.py | 16 | 0 | 0 |
| test_issue19_subscriber_daemon_lifecycle.py | 16 | 0 | 0 |
브리프 지정 7개 파일 + 바꾼 모듈의 테스트(러너: issue33/34·loop_b, env/진입점: issue5·issue18, bash 노브: o2·o3·b7·issue36·issue6·c1·b19·issue19). 전체 스위트(~60분)는 돌리지 않았다(오케스트레이터 몫). 정적 검사: `bash -n` 으로 바꾼 셸 파일 6개 문법 OK; `shellcheck`·`pyflakes` 는 이 호스트에 없다.
## 5. 위생
- 작업 트리: 최종 `git status --short --ignored` = `!! .venv/` 만. 단, 최종 실행 중 **`.mam/shim/herdr` 가 작업 트리에 생겼다** → 파일별로 나눠 돌려 원인을 찾았다: `tests/test_version_consistency.py::_get_lib_sh_mam_version` 이 `WORKSPACE_ROOT` 없이 `lib.sh` 를 source → `_init_herdr_isolation` 이 `<저장소>/.mam/shim/herdr` 를 쓴다. **base 에서도 똑같이 생긴다**(내 변경과 무관, 기존 동작). 내용이 재생성되는 심 하나뿐임을 확인하고 지웠다. ⚠️ 본 체크아웃에서 이 테스트를 돌리면 **실제 `.mam/shim/herdr` 를 덮어쓴다** — 후속 후보(테스트가 `WORKSPACE_ROOT` 를 샌드박스로 주기).
- 프로세스: 내 F5 확인(seam 을 일부러 뺀 S13 실패 실행)에서 **샌드박스 stall_watch 1개가 남았다**(S13 은 단언 실패 시 `finally` 에서 에이전트만 죽이고 watcher `w` 는 정리하지 않음 — 기존 테스트 구조). 그 watcher 는 basetemp 의 mock herdr·레지스트리만 상대했다. TERM 으로 종료했고, 이후 scratchpad 경로를 가진 프로세스 0개. 실제 모니터 데몬·세션은 건드리지 않았다(남은 `-m lib_py.loop` 1개는 오케스트레이터의 루프 드라이버).
- 실제 `.mam/jobs` 의 레코드는 읽기·쓰기 하지 않았다(이 리포트 파일만 작성).
## 6. 리뷰어 참고
- `lib.sh` 변경은 `mam_int`/`mam_int_knob` 블록을 `lib_int.sh` 로 옮기고 그 자리에서 `source "$(dirname "${BASH_SOURCE[0]}")/lib_int.sh"`. 생성되는 herdr 심(heredoc, 151–1367행)은 `mam_int` 를 쓰지 않는다.
- `local x="$(mam_int_knob …)"` 형태는 B5 정적 검사 정규식(`mam_int_knob NAME default min)`)이 잡도록 기존 호출부 모양을 따랐다. B5 가 이제 래퍼와 **모든** `.sh` 를 읽는다.
- 메모: 오래된 `.mam.env` export 지침(공개 브로커 시절)은 이 트리에서 틀리다 — conftest 가 세션 로컬 브로커를 띄우고 인증 변수를 지운다.
## 7. 교차 확인 명령
```
cd /Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux-wt/followups
git log --oneline 0051fd6..HEAD # 8 commits, head 95eabc6
git diff --stat 0051fd6..HEAD
grep -rn MAM_TEST_ .agents/skills # nothing
AGENT_PYTHON_BIN=$PWD/.venv/bin/python PYTHONDONTWRITEBYTECODE=1 .venv/bin/python -m pytest -p no:cacheprovider -q \
--basetemp=/tmp/<somewhere> tests/test_issue26_envfile.py tests/test_issue26_ints.py tests/test_issue21_acceptance.py \
tests/test_issue30_stall_watch.py tests/test_issue30_stall_watch_stop.py tests/test_tier1_unit.py tests/test_version_consistency.py
```
@@ -0,0 +1,14 @@
{
"loop_id": "loop-20261025T023502Z-d6f876",
"base_sha": "0461a632eb667c7988fd33be92e9a4b36151f22a",
"head_sha": "bb496fde785133ad1dbec288aa7431766efb6eb5",
"tree_sha": "15a72c9a1933c64018a2f2d9aa9707348905bb4d",
"review_commit": "bb496fde785133ad1dbec288aa7431766efb6eb5",
"reviewer": "planner-reviewer-claude-01",
"job_id": "05af786c",
"verdict": "PASS",
"transport": "herdr",
"evidence": "review-tree",
"isolation": "worktree",
"verified_at": "2026-10-25T03:40:44Z"
}
@@ -0,0 +1,104 @@
# PR #70 (Issue #66) 리뷰 — head bb496fd
- 리뷰어: planner-reviewer-claude-01 (macOS, bash 3.2, /usr/bin/python3 3.9.6)
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261025T023502Z-d6f876/1`
- identity 확인: HEAD bb496fde785133ad1dbec288aa7431766efb6eb5, tree 15a72c9a1933c64018a2f2d9aa9707348905bb4d, `git status --porcelain --ignored` 비어 있음, `.mam` 없음 (시작·종료 시 모두). main HEAD 0461a63 그대로.
- 범위: base 0461a63..bb496fd (커밋 1개). 바뀐 파일은 세 개다: `.agents/skills/lib_py/stall_watch.py`, `tests/conftest.py`, `tests/test_issue66_stall_watch_sanitized_name.py`.
## 1. 확인한 것과 근거
### 1) 완료 조건
herdr 를 부르는 곳은 모두 변환된 이름을 쓴다.
- `HerdrSource.__init__` 이 `self.name = sanitize_herdr_agent_name(target)` 를 만든다 (`stall_watch.py:162`).
- 이 이름을 쓰는 곳:
- `agent get` (:183)
- `agent read` (:206)
- `agent prompt` (:211)
- `watcher.wait_once(self.herdr_name, …)` (:331, 값은 :229)
- `pane process-info` 와 `pane read` (:187, :193)는 `agent get` 이 돌려준 pane_id 를 쓰므로 이름과 상관이 없다.
- `--herdr-session` 은 서버 이름으로만 쓰인다. `self.session` (:159) 과 `session=self.args.herdr_session` (:331) 이고, 변환되지 않는다. 둘을 섞어 쓴 곳은 없다.
로그와 알림은 원래 이름을 쓴다.
- 로그 :373 (blocked), :397 (입력창 비어 있지 않음), :406 (idle) 은 모두 `self.args.target` 이다.
- 알림 상세(`stalled:idle`, `stalled:blocked …`)에는 이름이 들어가지 않는다.
- 회귀 테스트는 `agent {target} idle for` 와 `agent {target} is blocked` 가 원래 이름으로 나오는지 확인한다 (test :85, :106).
변환이 필요 없는 이름(`creator-claude-t`)은 그대로이고, 그 경우의 테스트도 통과한다 (test :68 의 PLAIN).
### 2) 회귀 테스트: base 에서 실패, head 에서 통과
- head: 7 passed.
- base(0461a63) 사본에 head 의 새 테스트와 conftest 를 넣고 돌리면 5 failed, 2 passed 다.
- 실패: LONG, UPPER 두 이름 각각의 idle nudge 와 blocked 보고 (4개), 긴 codex 이름의 종류 판정 (1개).
- 통과: strict mock 자체를 확인하는 테스트와, 변환이 필요 없는 PLAIN.
### 3) 변환 함수가 lib.sh 와 같은 결과를 내는가
lib.sh `_sanitize_herdr_agent_name` (:293-316) 과 `lib_py.agents.sanitize.sanitize_herdr_agent_name` 에 같은 이름 10개를 넣어 비교했다.
- 비교한 이름: 그대로 둘 이름, 대문자, 32자 초과(루프 세션 이름 포함), 정확히 32자, 33자, 숫자로 시작, `.`·공백·`@`, 비 ASCII `Ü`, `_` 로 시작.
- 결과: 10개 모두 같고, 두 번 변환해도 바뀌지 않는다.
- 그래서 호출자가 이미 변환된 이름을 넘겨도 안전하다.
- 호출자는 원래 세션 이름을 넘긴다: `multi-agent-mux-delegate-job:431`, `:615` → `start_stall_watch "$AGENT_SESSION"` 와 `"$current_session"`.
### 4) resolve_agent 를 원래 이름으로 하는 것이 맞는가
맞다.
- 긴 이름은 변환되면 끝이 해시로 바뀐다. 예: `mam-wt-issue-66-very-long-creator-codex` → `…-<sha8>`. 그래서 종류는 원래 접미사에서 읽어야 한다.
- `HerdrSource` (:163) 과 `StallWatch` (:230) 둘 다 원래 이름을 쓴다.
- 테스트 :111-129 가 `--agent ""` 일 때 codex 로 정해져 nudge 가 가는 것을 확인한다.
- 종류를 변환된 이름에서 읽도록 바꾼 변이(m3)는 이 테스트에 잡힌다.
### 5) conftest `strict_agent_names`
- 기본(키 없음)일 때 `_match_agent` 는 예전과 똑같이 네 가지 비교를 한다 (`conftest.py` 수정분 :473-476). get/read 의 실패 출력도 예전 문구 그대로다. 그래서 기존 테스트의 동작은 바뀌지 않는다.
- 켜면 등록된 이름과 정확히 같을 때만 답하고, 아니면 `{"error":{"code":"agent_not_found"}}` 를 stderr 에 쓰고 exit 1 한다. 실제 herdr 처럼 등록 이름(변환된 이름)으로만 찾는다.
- 테스트 :51-65 가 get, read, wait, prompt 네 명령 모두 원래 이름에는 agent_not_found 를, 변환된 이름에는 rc 0 을 내는지 확인한다.
### 6) macOS 호환
- `/usr/bin/python3` 3.9.6 으로 `lib_py.stall_watch` import 와 `resolve_agent` 호출이 된다.
- 새 코드에 `X | Y`, `match`, tomllib 가 없다. 셸은 바뀌지 않았다.
### 7) test_issue17_item4_decisions::test_c_finalize_ends_with_an_unreaped_wrapper[broker_unavailable-3-0]
이 변경과 무관하다.
- 이 테스트는 래퍼의 finalize 를 자체 shim(`PY=<shim>`)으로 돌린다. stall watcher 는 argv 를 기록하는 대역이다 (`tests/test_issue17_item4_decisions.py:218`).
- conftest 의 mock herdr 와 `lib_py.stall_watch` 를 쓰지 않는다.
- 판정 조건은 `took < 4.5` 와 terminate 뒤 레코드가 그대로인지 (:598-601) 로, 시간과 정리 단계에 의존한다.
- 이 PR 이 바꾼 것(stall_watch 의 이름 변환, 기본 꺼짐인 mock 옵션)은 이 경로에 닿지 않는다.
- 이 호스트에서도 통과했다(아래 표).
## 2. 실행한 테스트
모두 `env -i`, 임시 HOME/XDG, `PYTHONDONTWRITEBYTECODE=1 -p no:cacheprovider --basetemp=<scratchpad>` 로 돌렸다. .mam.env 를 export 하지 않았다.
| 대상 | 결과 |
|---|---|
| 검증 경로 `tests/test_issue66_stall_watch_sanitized_name.py` | 7 passed |
| base 사본 + 새 테스트·conftest | 5 failed, 2 passed |
| 검증 경로: test_issue30_stall_watch, test_issue30_stall_watch_stop, test_issue17_item4_decisions, test_tier2_component, test_issue63_same_cwd_ids, test_issue50_dispatch_herdr, test_c1_tui_readiness (mock herdr 를 쓰는 인접 테스트) | 457 passed, 1 skipped, 0 failed |
변이 테스트 (head 사본, issue66 테스트):
| # | 변이 | 결과 |
|---|---|---|
| m1 | `wait_once` 를 원래 이름으로 | 잡힘 (5 failed) |
| m2 | `agent prompt` 를 원래 이름으로 | 잡힘 (3 failed) |
| m3 | 종류를 변환된 이름에서 판정 | 잡힘 (1 failed) |
| m4 | blocked 로그를 변환된 이름으로 | 잡힘 (2 failed) |
| m5 | `agent read` 를 원래 이름으로 | 잡힘 (3 failed) |
실제 herdr 서버(pid 19169 `herdr server`, 23492 `herdr --session multi-agent-mux server`)는 테스트 전후로 같다.
## 3. 차단 결함
없음.
## 4. 비차단
- **N1**: 변환된 이름을 두 곳에서 따로 만든다(`HerdrSource.name` :162, `StallWatch.herdr_name` :229). 주입된 source 를 쓰는 테스트 때문에 둘 다 필요해 보인다. 결과가 같으므로 동작에는 문제가 없다.
- **N2** (기존, 이 PR 과 무관): `--agent` 가 비어 있고 이름이 `…-claude-01` 처럼 숫자로 끝나면 `resolve_agent` 는 종류를 정하지 못한다(None). 실제 호출자는 항상 `--agent "$AGENT"` 를 넘기므로 지금은 영향이 없다.
- **N3** (기존, 이 PR 과 무관): lib.sh 에 `_sanitize_herdr_agent_name` 이 두 벌 있다(:36, :293). sha 도구가 없을 때의 대처가 서로 다르다(:36 은 32자로 자르고, :293 은 python3 로 해시). 이 호스트처럼 shasum 이 있으면 두 벌 모두 Python 판과 같다.
[VERDICT: PASS]
@@ -0,0 +1,128 @@
# 🔍 Review Report — PR #38 병합 전 독립 리뷰 (job `107d435d`)
- **Reviewer**: `planner-reviewer-claude-01` (role: planner,reviewer) · 2026-09-25
- **대상**: PR #38 `fix/issue-34` **@ `ed293dc`**, base `main 7a4a0c2` (v4.7.3). 6커밋, 11파일 `+924/−16`, `git diff --check` 클린.
- **검증 경로**: `/var/folders/.../T/mam-review/loop-20260925T055502Z-a37cf1/1`. **identity 확인**: `HEAD = ed293dce…67cf`, `HEAD^{tree} = 671b0e59…899c` — 브리프와 일치.
- **안전 규칙 준수**: 검증 경로·소스 워크트리(`multi-agent-mux-wt/pr-38`)·본 체크아웃(`main 7a4a0c2`) 모두 수정·커밋·checkout 없음. 등록부 실험은 scratchpad 임시 등록부에서 `registry.py` 를 직접 호출(래퍼 미사용, 실제 herdr 에 잡 생성 없음), 러너 실험은 in-process(`env -u MAM_VERSION`, 임시 git 저장소), lib.sh 는 함수 추출 + `env -i`. 실제 `.mam`·데몬·세션·herdr 서버 조작 없음, push·merge·PR 상태 변경 없음. 이 pane 의 `HERDR_PANE_ID`/`HERDR_WORKSPACE_ID` 는 **읽기만** 했다.
## 결론 요약
**병합을 막을 결함은 없다.** 부모 기록·자손 대기·리뷰 근거 표·stale 가드·같은 서버 다른 워크스페이스 조회 규칙을 각각 독립 실험으로 확인했고, 새 테스트 17건 중 **16건이 수정 전 소스에서 실패**한다. 비차단 관찰 4건(K1–K4). 그중 K1(귀속되지 않은 열린 잡만 있을 때 판정 규칙 문구가 없음)은 브리프 항목 2·4 의 "충분한 근거" 질문에 직접 닿는다.
## 1. 부모 기록 (항목 1)
| 확인 | 결과 |
|---|---|
| `--parent-job` 검증 | `registry.py register`(`:540-544`) 가 등록부에 없는 부모를 `unknown parent job <id>` + rc 1 로 거부. 래퍼(`:217-226`)는 명시 `--parent-job` 을 그대로 넘겨 **같은 검증**에 걸리게 하고, 기본 `MAM_JOB_ID` 는 이 등록부에 그 레코드가 있을 때만 쓰며 없으면 `WARNING: … registering without a parent job` 후 부모 없이 등록 — 두 층의 규칙이 일치한다 |
| 자기 참조 | `--job-id X --parent-job X` 는 등록 시점에 X 가 없어 거부됨(실측). `update` 에는 `--parent-job` 이 없어 **부모는 등록 후 불변** → 정상 경로로는 순환이 만들어질 수 없다 |
| 순환 방어 | 레코드 파일을 직접 고쳐 `root.parent = grandchild` 순환을 만든 뒤 `descendants_of` 를 돌려도 종료한다(`seen` 집합, 방문 1회): root 기준 `[(child,1),(grandchild,2)]`, grandchild 기준 `[(root,1),(child,2)]` |
| `depth` | 저장하지 않고 BFS 에서 계산(자식 1, 손자 2 — 실측) |
| 비용 | `descendants_of` 는 폴링(3 s)마다 `list_jobs` **전체 스캔** 1회(레코드 수 N 의 JSON 읽기). `_subjobs_block` 은 회차마다 root 수만큼 스캔 + `unattributed_open_jobs` 1회. 수백 건이면 문제없고 수천 건이면 3 s 폴링당 수천 파일 읽기 — 등록부 정리 정책이 없는 오래된 제어 평면에서 체감될 수 있다(K4) |
| `MAM_JOB_TREE_MAX`(50) | 넘으면 `log_error` 후 **기다리지 않고** 리뷰로 간다. 안전한 쪽인가: 열린 자손은 `[하위 위임 잡]` 표에 OPEN 으로 나오고 SUBJOB_RULE 이 NOT PASS 를 지시하므로 "조용히 통과"는 아니다. 다만 51개 이상의 행이 프롬프트에 붙는다 |
## 2. 부모 ID 전달 (항목 2)
- 로컬 에이전트: 래퍼가 주입문 끝(`_instructions`, test18 문구 보존)과 `brief.md` 의 `Parent for sub-delegations` 줄(`:251`, `:427`), create 의 온보딩 주입문(`create_session.sh:646`)으로 알린다. 에이전트가 붙이지 않으면 추적이 빠지는 것은 사실이고, 안전망은 두 가지다: (a) `[귀속되지 않은 열린 잡]` 줄 — `unattributed_open_jobs` 가 "부모 없음 + pending/running + 이 루프가 만든 잡 제외 + creator 잡 등록 이후" 로 고른다(실측: 루프 자신의 뒤이은 잡은 제외, 남의 root 잡은 표시); (b) `--wait-open-jobs` 로 그 잡들까지 대기(기본 off — 같은 제어 평면의 무관한 잡이 루프를 잡을 수 있으므로; 문서와 일치).
- **K1 (비차단, 근거 충분성)**: 하위 잡이 하나도 없고 귀속되지 않은 열린 잡만 있을 때 블록은 `[하위 위임 잡] 없음 … [귀속되지 않은 열린 잡]: <id>(…)` 만 담고 **SUBJOB_RULE 을 붙이지 않는다**(`_subjobs_block` 의 `if not rows:` 분기 — 실측: `has rule: False`). SUBJOB_RULE 자체도 "하위 잡"만 말하고 귀속되지 않은 잡을 어떻게 볼지는 말하지 않는다. 즉 "creator 가 `--parent-job` 을 빠뜨린" 바로 그 경우에 리뷰어는 사실은 받지만 지시는 받지 못한다. 한 문장("귀속되지 않은 열린 잡이 이 작업의 하위 위임으로 보이면 OPEN 하위 잡과 같이 취급")을 stray 줄 뒤에 붙이면 닫힌다.
- 원격 러너: `MAM_JOB_ID` 가 자동 부모가 되지만 원격 호스트의 등록부에는 그 잡이 없을 수 있어 "있을 때만" 규칙이 맞다(래퍼 주석과 일치).
## 3. 자손 대기 (항목 3)
- `wait_for_job_tree` 는 실행(`:1291`)·수정(`:1520`)·순응(`:1591`) 세 creator 잡 모두에 걸리고, `round_roots` 에 누적된다.
- `MAM_CHILD_WAIT_S`(3900) 의 기준점은 `wait_for_descendants` 진입 시각 = **creator 잡 종결 시점**(문서와 일치). 초과 시 `log_warn` 후 False 를 돌려주지만 `wait_for_job_tree` 는 root 의 결과만 반환하므로 루프는 리뷰로 진행하고 열린 잡은 표에 OPEN 으로 남는다(설계대로).
- `interrupted`: 시그널 핸들러가 체크포인트 저장 후 `sys.exit(128+signum)` 하므로(`_handle_signal`) 폴링 루프의 `if self.interrupted: return False` 는 실제로는 닿지 않는 방어 코드다 — 무해.
- 분류(`_subjob_state`)와 `get_feedback` 의 실제 값: 래퍼는 마감 시 `close --request deadline_timeout` 뒤 `publish_event --event error --detail deadline_timeout`(inject 단계면 `agent_blocked`)을 남기고(`:928-930`), preflight/idle 실패는 `cancelled --detail session_busy` 또는 새 `target_not_found`(`:896`, `:952`), create 롤백은 `cancelled "rolled back by create_session"` 이다. `get_feedback` 은 마지막 `completed/error`(감사 로그) 또는 events.log 의 detail 을 돌려주므로 `ORCHESTRATOR_CLOSED` 튜플·`startswith("rolled back")`·`session_busy|target_not_found` 가 정확히 그 문자열들과 맞는다. 사유 없는 `error` 를 CLOSED-UNCONFIRMED 로 보는 규칙은 "에이전트 자신의 보고가 아니다" 는 뜻에서 보수적이며 옳다.
## 4. 리뷰어 근거 (항목 4)
in-process 실측(`_subjobs_block`):
| 상황 | 결과 |
|---|---|
| 하위 잡 없음·stray 없음 | **`''`** — 프롬프트 바이트 동일 |
| 이 루프가 뒤에 만든 잡(`created_jobs`)만 존재 | `''` — 리뷰어 자신의 잡·앞 회차 리뷰 잡은 stray 로 오인되지 않는다(회차의 블록은 리뷰 잡 위임 **전에** 계산된다) |
| 남의 root 잡(부모 없음, creator 잡 이후 등록) | 블록 생성, stray 줄에 id 표시, **규칙 문구 없음**(K1) |
| pending 자식 1개 | 표(`\| OPEN \| - \| 1 \|`) + 집계 + SUBJOB_RULE |
`descendants_of` 가 `depth` 를 주고 `_subjob_state` 가 상태·사유를 주므로 표의 열은 정확하다. 표는 앞 회차 creator 잡의 자손까지 매 회차 다시 읽는다(`round_roots` 누적, T9).
## 5. stale 가드 — 옵션 없는 루프 (항목 5)
`_fingerprint`(`runner.py:954-972`)는 `head` 와, `.mam`(`mam_dir`)·`jobs_dir`·감사 로그 디렉터리(`DELEGATE_JOB_LOGS_DIR`, 상대면 `workspace_root` 기준으로 결합)를 `git add -A -- . :(exclude)<rel>` 로 뺀 tree 다. in-process 실측(임시 git 저장소, **`.gitignore` 없음** — 즉 `.mam` 이 무시되지 않는 최악 조건):
| 상황 | 결과 |
|---|---|
| `DELEGATE_JOB_LOGS_DIR=.mam/delegate_job_logs`(상대) 로 감사 로그·`jobs/*.json` 을 새로 쓴 뒤 | 지문 **불변** — 제어 평면 쓰기는 제외된다 |
| 소스에 untracked 파일 1개 추가 | 지문 **변경** |
| 감사 로그 디렉터리가 소스 **밖**(절대경로) | `relative_to` 가 `ValueError` → 제외 목록에서 빠지고 지문 계산은 정상 |
- **켜지는 조건**: `light = self._fingerprint()` 는 `not identity and subjobs`(`:1350-1352`) — 하위 잡 표나 stray 줄이 있을 때만이다(1d4f338). 하위 위임 없는 루프는 옛 동작 그대로라 `test_tier4_e2e.py::test_e2e_scenario5` 류(소스에 매 잡 바뀌는 untracked 가 있는 경우)가 다시 살아난다 — 이번 실행에서 tier4 포함 통과(§7).
- **적용 지점**: 셀프 리뷰 뒤(`:1368`), 리뷰 판정 수집 뒤(`:1463`, identity 모드의 B7 과 `elif` 로 배타), 승격 직전(`:1595`, identity 모드의 B10 은 `:1613` 로 별도). identity 모드에서는 `light` 가 `None` 으로 남아 **이중 적용이 없다**.
- 지문을 만들 수 없으면(예: dirty 서브모듈) 경고 1회 후 가드 off — 옵션 없는 모드의 기존 동작으로 되돌아가는 것이라 수용 가능하다.
## 6. 같은 서버 다른 워크스페이스 위임 (항목 6)
`_herdr_scope_is_pane_inherited`(`lib.sh:1464-1473`)와 `mam_herdr` 의 `elif`(`:1485-1486`)를 함수 추출 + `env -i` 로 10가지 조합에 돌렸다(전부 설계와 일치):
| 호출 | 환경 | 결과 |
|---|---|---|
| `agent`/`has-session`/`kill-session`(`-L` 접두 포함) | `HERDR_WORKSPACE_ID == HERDR_PANE_ID` 의 워크스페이스, 같은 서버 | **WS id 만 제거**(pane·tab id 유지) |
| `new-session`, `pane split` | 동일 | **유지** — pane 배치·워크스페이스 패킹(#7) 불변 |
| `agent` | WS id ≠ pane 의 워크스페이스(어긋난 스코프) | 유지(의도된 스코프) |
| `agent` | pane id 없음(pane 밖 명시 스코프) | 유지 |
| `agent` | 기본 서버 pane → 이름 있는 서버 | **CROSS-all3** — #23 규칙이 `if` 로 먼저 잡아 세 id 모두 제거, 이 규칙은 `elif` 라 닿지 않는다(충돌 없음) |
| `agent` | 대상 미지정(앰비언트) | WS id 만 제거(같은 서버이므로 같은 취급) |
- 이 pane 의 실제 값도 규칙의 전제(`HERDR_PANE_ID = "<ws>:<pane>"`, `HERDR_WORKSPACE_ID = <ws>`)와 맞았다(값은 읽기만).
- 이름 유일성 근거(herdr `--skill` 문구)는 코드 주석에 인용돼 있고, T10/T11 이 두 방향을 고정한다.
- `target_not_found`: preflight 의 `has-session` 실패가 `PREFLIGHT_REASON` 을 세우고(`:952`) `finalize_unfinished` 가 `cancelled --detail target_not_found` 로 닫는다(`:896`). **rc 는 종전과 같은 5** 다(T12 가 `returncode == 5` 를 고정) — 즉 이벤트 detail 은 정확해졌지만 종료 코드는 "busy" 와 같아서, 루프의 busy 1회 재시도(`MAM_SESSION_BUSY_RETRY_S`)가 없는 대상에도 한 번 더 돌아간다(K3).
## 7. 새 테스트 (항목 7)
- **실제 경로**: T1·T12 는 실제 래퍼(`--agent human`, 도달 불가 브로커)와 `registry.py` 로 등록·종결한다. T3~T9 는 `python -m lib_py.loop` 를 하위 프로세스로 띄우고 가짜 래퍼가 **실제 래퍼**(`FAKE_REAL_WRAPPER`)로 하위 잡을 등록해 자손 대기·표·가드를 통과시킨다. T10~T12 는 mock herdr 위의 샌드박스. `HOME`·`WORKSPACE_ROOT`·`DELEGATE_JOB_LOGS_DIR` 는 `tmp_path`.
- **수정 없이는 실패하는가 — 확인함**: 검증 경로 사본에서 PR 이 바꾼 소스 6파일을 `7a4a0c2` 판으로 되돌리고 새 파일을 돌린 결과 **16 failed / 1 passed**(16.7 s). 통과 1건은 T11("호출자가 고른 워크스페이스 id 는 여전히 스코프") — 정정 전에도 성립해야 하는 불변 조건이다.
- **검증 경로에서의 실행** (9파일: 새 파일 + issue5 phase1·tier4 e2e·issue21 loop B/acceptance·issue23/24·loop_cli·issue25·herdr shim contract):
```
→ 1 failed, 243 passed in 1460.19s (0:24:20)
FAILED tests/test_issue23_24_startup_path.py::test_s7c_a_started_that_was_followed_by_a_terminal_status_still_counts
```
- 새 파일 17건 **전부 통과**, `test_tier4_e2e.py::test_e2e_scenario5`(브리프 항목 5 의 회귀) **통과**(873 s — 이 호스트에서 다른 스위트가 동시에 돌아 load avg 2.5 였다), registry CLI 스냅샷·help 캡처(issue5 phase1) 통과 → `--parent-job` 추가가 기존 CLI 계약을 깨지 않는다.
- **실패 1건은 부하 flake** 로 판정한다: s7c 는 create/registry 의 `started` 증거 시험이며 PR 은 그 경로를 건드리지 않았다(create 는 주입문에 문장 하나 추가뿐). 합동 실행에서 40 s 걸려 `_seed_job` 단계(`--ack-timeout 2`)에서 실패했고, **단독 재실행에서는 s7b 와 함께 2 passed in 10.8 s** 로 통과했다.
- **격리**: 실제 `.mam/jobs`·`.mam/delegate_job_logs` 전후 **차이 0 / 차이 0**. 검증 경로 tracked 파일 변화 없음(HEAD `ed293dc`), 본 체크아웃 `main 7a4a0c2`.
- **실행 시간**: 새 파일 자체는 되돌린 사본 기준 17 s 안팎이고, 합동 실행의 24 분은 tier4 e2e(약 17 분)와 호스트 부하 몫이다.
## 8. 문서 (항목 8)
- loop SKILL.md `:214`(`--wait-open-jobs` 표), `:221`(하위 위임 잡: 대기 범위·`MAM_CHILD_WAIT_S` 기준점·`MAM_JOB_TREE_MAX`·표/규칙·회차 누적), `:223`(stale 가드) — 코드와 일치한다.
- delegate-job SKILL.md `:49-53`·Best Practices, MULTI_AGENT_RULES(ko/en) `:154` — `--parent-job` 의무와 기본값·거부 규칙이 서술돼 있다.
- 래퍼 usage 와 `brief.md` 의 `Parent for sub-delegations` 줄, create 온보딩 주입문 — 모두 같은 문장이다.
## 9. 병합 차단 여부 (항목 9)
버전 락스텝 bump 부재(`lib.sh:32` `4.7.3`, 오케스트레이터가 4.7.4 로 처리 예정) 외에 **병합을 막을 결함은 없다**.
| # | 비차단 관찰 | 근거 / 권고 |
|---|---|---|
| K1 | 귀속되지 않은 열린 잡**만** 있을 때 블록에 SUBJOB_RULE 이 없고, 규칙 문구도 그 잡을 언급하지 않는다 — `--parent-job` 을 빠뜨린 바로 그 경우에 리뷰어가 지시 없이 사실만 받는다 | `runner.py:697-724` 의 `if not rows:` 분기, 실측 `has rule: False`. stray 줄 뒤에 한 문장 권고 |
| K2 | `MAM_JOB_TREE_MAX` 초과 시 대기를 건너뛰고 리뷰로 간다 — 표의 OPEN 행과 규칙이 NOT PASS 를 유도하므로 조용한 통과는 아니지만, 50+ 행이 프롬프트에 붙는다 | `:646-654`. 정보 |
| K3 | `target_not_found` 는 이벤트 detail 만 바뀌고 래퍼 rc 는 `session_busy` 와 같은 **5** 라 루프의 busy 1회 재시도가 없는 대상에도 돈다 | `:896`, `:952`, T12 `returncode == 5`. rc 분리 권고(후속) |
| K4 | `descendants_of`/`unattributed_open_jobs` 는 폴링(3 s)마다 등록부 전체 JSON 을 읽는다 — 수천 건 누적된 제어 평면에서 체감 가능 | `registry.py:259-282`. 등록부 정리 정책 또는 인덱스가 생기기 전까지는 정보 |
## 10. 교차검증 가이드
```bash
RP=/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20260925T055502Z-a37cf1/1
PY=/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.venv/bin/python
git -C "$RP" rev-parse HEAD 'HEAD^{tree}' # ed293dc… / 671b0e5…
# 등록부: 부모 기록·list --parent·미지 부모/자기 참조 거부 (임시 등록부, registry.py 직접)
# descendants_of: 손자 depth 2, 레코드 파일로 순환을 만들어도 종료
# 러너 in-process (env -u MAM_VERSION): _subjobs_block '' / stray / child 표, _fingerprint 의 .mam 제외
# lib.sh: _herdr_scope_is_pane_inherited 10케이스 (§6)
# 되돌린 사본: 소스 6파일을 7a4a0c2 판으로 → tests/test_issue34_job_tree.py → 16 failed / 1 passed
(cd "$RP" && AGENT_PYTHON_BIN="$PY" PYTHONDONTWRITEBYTECODE=1 "$PY" -m pytest -p no:cacheprovider -q --basetemp=/tmp/bt \
tests/test_issue23_24_startup_path.py::test_s7c_a_started_that_was_followed_by_a_terminal_status_still_counts) # 단독 통과
```
[VERDICT: PASS]
@@ -0,0 +1,14 @@
{
"loop_id": "loop-20261008T133858Z-9e7f5b",
"base_sha": "6b00b6e06f55299cb60f156ccef09ede4bae5bdd",
"head_sha": "ac48cb78617af88584db529b5766acf02f4af73c",
"tree_sha": "b043034eda891bd83b547dcb34d7422a6977b89d",
"review_commit": "ac48cb78617af88584db529b5766acf02f4af73c",
"reviewer": "planner-reviewer-claude-01",
"job_id": "1364df46",
"verdict": "PASS",
"transport": "herdr",
"evidence": "review-tree",
"isolation": "worktree",
"verified_at": "2026-10-08T18:40:20Z"
}
@@ -0,0 +1,35 @@
# 리뷰 리포트 — Job 1364df46 (Issue #17 후속 3회차: 상속 WORKSPACE_ROOT, 6b00b6e..ac48cb7)
- 리뷰어: planner-reviewer-claude-01 (claude)
- 리뷰 identity: loop-20261008T133858Z-9e7f5b / base 6b00b6e / head ac48cb7 / tree b043034 — 검증 경로(…/3)에서 `git rev-parse HEAD 'HEAD^{tree}'` 일치, 미커밋 변경 없음
- 범위: 2회차 head 8b47cc7 위에 커밋 1개(ac48cb7, +9줄): test_o2_race_free_lock.py 의 o2_12 에 테스트 단위 `own_workspace_root` 마크, test_o1_rebuttal.py 에 모듈 마크, W1 NODES 에 두 노드 추가. 제품 코드·버전·VERSIONS 변경 없음.
## 결론 요약
2회차의 차단 지적(test_o2_race_free_lock.py:196-203 이 run_loop.sh → lib.sh 로 자기 체크아웃의 `.mam/shim/herdr` 를 씀)이 닫혔고, grok 이 지적한 test_o1_rebuttal 도 같은 방식으로 덮였다. 2회차에서 깨끗했던 나머지 후보들은 그대로 깨끗하다. 차단 결함 없음.
## 1. 지적 종결
| 지적 | 수정 | 확인 |
|---|---|---|
| claude(2회차): o2_12 가 `dict(os.environ)` 로 run_loop.sh 실행, 마크·sandbox 없음 → 체크아웃 shim 재작성 | test_o2_race_free_lock.py:196-198 — o2_12 에만 `@pytest.mark.usefixtures("own_workspace_root")`(모듈 마크를 피한 이유: o2_7 이 tmp_path 항목 수를 센다 — 타당); W1 NODES 에 o2_12 추가(:41) | HEAD 사본에서 `WORKSPACE_ROOT=<사본>` 으로 o2_12 단독·파일 전체(22 passed) 모두 `.mam` **미생성**; `WORKSPACE_ROOT` 미설정으로도 미생성(2회차에는 두 경우 모두 생성) |
| grok: test_o1_rebuttal 도 run_loop.sh 실행 | 모듈 마크(:10-12), W1 NODES 에 v3 노드 추가(:42) | HEAD 사본 파일 전체(10 passed) 미생성. 새 W1 노드가 의미 있는지: base 트리에서 그 노드 1개를 `WORKSPACE_ROOT=<base 사본>` 으로 실행 → `.mam/shim/herdr` **생성** — 수정 전 결함을 실제로 고정한다 |
## 2. 놓친 모듈이 더 없는가 (2회차 방법 반복)
HEAD 트리 사본에서 `WORKSPACE_ROOT=<사본>` 으로 내 후보 13개 파일(test_o2, test_o1, test_issue30_stall_watch_stop, test_issue36_prompt_transport, test_issue19, test_a4, test_issue21_acceptance, test_issue20, test_issue34, test_issue23_24, test_b4, test_issue15, test_issue10_13; lib.sh 를 source 하는 코드를 실행하면서 sandbox 인자가 없는 테스트 50개)을 파일별로 돌리고 매번 `.mam` 을 확인: **13개 모두 clean**. 2회차에 유일하게 썼던 o2 가 이제 깨끗하다.
## 3. 할 일 4 — 실행 (검증 경로, 워크트리 .venv Python 3.9.6, `env -i` 로 HERDR_*·MAM_*·MQTT_* 제거)
| 실행 | 결과 |
|---|---|
| (a) WORKSPACE_ROOT 없이: 수정 16개 파일(W1·W2 포함) + test_issue33_budget, test_issue5_phase1_delegation, test_tier1_unit | **675 passed / 0 failed** (862 s) |
| (b) WORKSPACE_ROOT=<워크트리 밖 센티널>: 수정 16개 파일 | **522 passed / 0 failed** (707 s); 실행 뒤 센티널 **비어 있음** |
| 본 체크아웃 .mam/shim/herdr mtime | 전후 동일(1791480333); 실제 .mam/jobs 항목 수 불변 |
- W1 은 (a)(b) 양쪽에서 통과했고, 이제 15개 노드(모듈 15개)를 체크아웃 사본에서 돌려 `.mam` 미생성을 고정한다.
- 이식성: Python 테스트 코드만. bash 3.2·macOS 영향 없음.
## 4. 1·2회차 판단 유지
`_env` 의 tmp_path 지정(lib.sh:26 기본값 때문), conftest `pytest_configure` 의 "다른 체크아웃만 제거" 정책과 opt-in fixture 설계(autouse 불가 이유 포함), unittest 클래스의 명시 env, test_orc_onboard 의 호출 시점 root — 모두 이전 리포트대로 타당하다. W1·W2 가 base 에서 실패함은 2회차에 확인했다.
## 5. 비차단
- W1 의 모듈 목록은 수동 유지다(2회차 비차단 그대로). 새 테스트가 lib.sh 를 source 하는 모듈을 추가하면 목록도 늘려야 한다는 점을 conftest 의 fixture docstring 이나 W1 머리말에 한 줄 적어 두면 좋다.
- 새 관찰 없음.
[VERDICT: PASS]
@@ -0,0 +1,14 @@
{
"loop_id": "loop-20260924T085626Z-b8987e",
"base_sha": "59be1c89209a232059aec0c5aeecc2329929cb49",
"head_sha": "708262a598c4301c0ccc70efba5401093b4b1e30",
"tree_sha": "d601c2d01bb4c09931e5e1989e140b01d51f5b96",
"review_commit": "708262a598c4301c0ccc70efba5401093b4b1e30",
"reviewer": "planner-reviewer-claude-01",
"job_id": "1a9fbbc4",
"verdict": "PASS",
"transport": "herdr",
"evidence": "review-tree",
"isolation": "worktree",
"verified_at": "2026-09-24T09:09:43Z"
}
@@ -0,0 +1,97 @@
# 🔍 Review Report — PR #37 델타 리뷰 2회차 (job `1a9fbbc4`)
- **Reviewer**: `planner-reviewer-claude-01` (role: planner,reviewer) · 2026-09-24
- **대상 델타**: `59be1c8..708262a` (1커밋, 3파일 `+63/−7`, `git diff --check` 클린), base `main 1debc7d` (v4.7.2).
- **검증 경로**: `/var/folders/.../T/mam-review/loop-20260924T085626Z-b8987e/1`. **identity 확인**: `HEAD = 708262a5…1e30`, `HEAD^{tree} = d601c2d0…5b96` — 브리프와 일치.
- **1회차 내 판정**: PASS + H1~H5 (job `4ab12ae2`). 이번에는 H1(러너 쓰기 실패)·H3(빈 파일)·S9 이식성의 정정을 본다.
- **안전 규칙 준수**: 검증 경로·소스 워크트리·본 체크아웃(`main 1debc7d`) 모두 수정·커밋·checkout 없음. 러너 실험은 in-process 스텁, 래퍼는 `--dry-run`+`--agent human`+도달 불가 브로커, 되돌린 사본은 scratchpad. 실제 herdr·세션·`.mam`·데몬 조작 없음, push·merge·PR 상태 변경 없음.
## 결론 요약
**세 정정 모두 지적을 닫고, 1회차 판정을 뒤집을 요소는 없다.** 내가 1회차에서 H1 을 재현했던 바로 그 스크립트를 정정된 러너에 다시 돌려 "실패 제출·부분 파일 없음·래퍼 미실행"을 확인했고, 정상·exec 실패·쓰기 실패 세 경로에서 파일이 **정확히 한 번**만 지워지는 것도 확인했다. 비차단 관찰 1건(I1).
## 1. R1/H1 — 러너의 쓰기 실패 (항목 1)
`runner.py:472-491`: `mkdir`·`NamedTemporaryFile`·`write` 가 하나의 `try` 로 들어가고, `prompt_file` 은 `""` 로 초기화된 뒤 파일이 생기는 즉시(`write` 이전) 이름을 받는다. `except OSError` 는 이름이 있으면 지우고 `CompletedProcess(…, 126, "", "cannot write the prompt file under <mam_dir>: <err> (prompt N bytes)")` 를 돌려준다. exec 용 `try/finally`(`:504-516`)는 그 뒤에 따로 있어 두 블록이 겹치지 않는다.
### 실측 (검증 경로 러너 in-process)
| 경로 | 결과 |
|---|---|
| **쓰기 실패**(1회차 H1 재현 스크립트 그대로: `write` 가 `OSError(28)`) | `delegate_job` 이 **None** 을 반환(예외 없음), 로그 `cannot write the prompt file under …: [Errno 28] No space left on device (prompt 5 bytes)`, `.mam` 잔존 **0**, 래퍼 호출 **0** |
| 정상 | 래퍼 호출 시점에 파일 존재·내용 `'hello'`·권한 **0600**, `os.unlink` 호출 **1회**, 잔존 0 |
| exec 실패(`subprocess.run` 이 `OSError(2)`) | rc 126 경로, `os.unlink` 호출 **1회**, 잔존 0, 반환 None |
- 반환하는 `CompletedProcess` 는 exec 실패와 같은 rc 126 이라 기존 처리(`submit failed (exit 126)` 로그, busy 재시도 없음, id 없음 → `failed_submit_jobs` 미등록·None 반환 → 호출자 `return 1`)에 그대로 합류한다. S4c 가 `submit failed (exit 126)`·`cannot write the prompt file`·`Traceback` 부재·래퍼 미실행·잔존 없음을 한꺼번에 단언한다.
- `NamedTemporaryFile` 생성 자체가 실패하면 `prompt_file` 이 빈 채로 `except` 에 들어가 `unlink` 를 건너뛴다 — 지울 것이 없으니 맞다. `mkdir` 실패도 같은 블록이다.
## 2. H3 — 빈 `--prompt-file` 거부 (항목 2)
`multi-agent-mux-delegate-job:193` 의 `[[ -s "$PROMPT_FILE" ]]` 는 **래퍼 `submit` 의 입력**에만 걸린다. 정당한 빈 프롬프트 사용처를 막는지 실측:
| 경로 | 결과 |
|---|---|
| 래퍼 `submit --prompt-file empty.md` | `submit: --prompt-file <절대경로> is empty`, rc 1 (S1e) |
| 래퍼 `submit --prompt-file newline.md`(개행 1바이트) | 등록됨 — `-s` 는 바이트 크기 기준이라 공백만 있는 파일은 통과한다(I1) |
| 래퍼 `submit --prompt ""` | 종전대로 `submit requires --prompt or --prompt-file` 거부 — 텍스트 형식과 대칭 |
| `registry.py register --prompt-file empty.md`(직접) | 등록됨(rc 0) — 등록부는 그대로 |
| loop 형 회차의 `update --prompt-file "$job_dir/prompt.md"` | `cmd_submit` 을 거치지 않으므로 검사 밖(변경 없음) |
즉 새 검사는 "빈 텍스트를 거부하던" 기존 규칙을 파일 형식으로 확장한 것이고, 등록부·loop 회차 경로는 건드리지 않는다. 막히는 정당한 사용처는 없다.
## 3. R2/N1 — S9 의 macOS 이식성
`sys.platform == "linux"` 일 때만 `File too large` 를 단언하고, rc≠0·`prompt-shloop-*` 잔존 없음·`delegate_job_safe: cannot write the prompt file` 은 모든 플랫폼에서 단언한다. 1회차에서 내가 하네스를 직접 돌려 확인한 macOS 동작(rc 1·잔존 없음·원인 문구 출력)과 정확히 일치하는 계약이다.
## 4. 테스트 (항목 1·3)
### 4-1. 수정 없이는 실패하는가 — 확인함
검증 경로 사본에서 `runner.py` 와 래퍼만 `59be1c8` 판으로 되돌리고 `-k "s4c or s1e or s9 or s4 or s1"` 을 돌렸다:
```
2 failed, 6 passed, 1 skipped in 3.11s
FAILED test_s4c_a_prompt_file_that_cannot_be_written_is_a_failed_submit ← H1
FAILED test_s1e_an_empty_prompt_file_is_refused ← H3
```
S9 는 되돌린 사본에서도 통과한다 — `run_loop.sh` 쪽 수정은 이미 `59be1c8` 에 있었고 이번 델타는 테스트의 단언만 이식 가능하게 바꿨기 때문이다(macOS 에서 통과 = R2 정정 확인). S1/S1c/S1d/S4/S4b 는 양쪽 모두 통과(불변 조건).
### 4-2. 검증 경로에서의 실행
```
tests/test_issue36_prompt_transport.py tests/test_issue5_phase1_delegation.py tests/test_loop_cli.py
tests/test_issue14_delivery_ack.py tests/test_issue25_plan_reuse.py
→ 127 passed, 1 skipped in 215.94s failed 0 (skip = linux_only S1b)
```
1회차의 유일한 실패였던 S9 가 이 호스트에서 통과하고, 새 파일 24건(23 통과 + Linux 전용 skip 1)과 인접 계약 스위트가 모두 통과한다.
### 4-3. 격리
| 항목 | 결과 |
|---|---|
| 실제 `.mam/jobs` 전후 | **차이 0** |
| `prompt-*` 잔존(실제 `.mam` / 검증 경로 `.mam`) | **0 / 0** |
| 검증 경로 tracked 파일 | 변화 없음(HEAD `708262a`), 본 체크아웃 `main 1debc7d` |
## 5. 병합 차단 여부 (항목 3)
1회차 PASS 를 뒤집을 요소는 없다. 델타는 러너의 `try` 범위 확장(`runner.py:472-491`), 래퍼의 `-s` 한 줄(`:193`), 테스트 3건(S4c·S1e·S9 조건부 단언)뿐이고, 정정 대상만 정확히 실패→통과로 바뀌었다. 버전 락스텝 bump 부재(`4.7.2`, 오케스트레이터가 4.7.3 으로 처리 예정) 외에 **병합을 막을 결함은 없다**. 1회차 H2(loop SKILL.md 의 `MAM_DIFF_MAX_*` 의미)·H4(`$PROMPT` 후행 개행)는 이 델타 범위 밖이라 그대로 열려 있다.
| # | 비차단 관찰 | 근거 |
|---|---|---|
| I1 | `-s` 는 바이트 크기 검사라 개행·공백만 있는 프롬프트 파일은 통과한다. 텍스트 형식(`--prompt ""`)의 거부와 완전히 대칭이려면 `[[ -n "$(tr -d '[:space:]' < "$PROMPT_FILE")" ]]` 류가 필요하지만, 실무에서 그런 입력은 없다 | `multi-agent-mux-delegate-job:193`, 실측 |
## 6. 교차검증 가이드
```bash
RP=/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20260924T085626Z-b8987e/1
PY=/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.venv/bin/python
git -C "$RP" rev-parse HEAD 'HEAD^{tree}' # 708262a… / d601c2d…
# H1 재현 스크립트(1회차 §1 의 것)를 정정된 러너에 → returned None, leftover [], wrapper called 0
# 되돌린 사본: runner.py·래퍼를 59be1c8 판으로 → -k "s4c or s1e or s9 or s4 or s1" → 2 failed / 6 passed / 1 skipped
(cd "$RP" && AGENT_PYTHON_BIN="$PY" PYTHONDONTWRITEBYTECODE=1 "$PY" -m pytest -p no:cacheprovider -q --basetemp=/tmp/bt tests/test_issue36_prompt_transport.py) # 23 passed, 1 skipped
```
[VERDICT: PASS]
@@ -0,0 +1,14 @@
{
"loop_id": "loop-20261019T015801Z-05463c",
"base_sha": "1d7b7dab58a10fe86fd747b7290d47674ca93230",
"head_sha": "6a37c0019baa31ee28b32a086e57b4c8239413c4",
"tree_sha": "87013e7ef7f5239f1bf6ea38a2cbda214257be3c",
"review_commit": "6a37c0019baa31ee28b32a086e57b4c8239413c4",
"reviewer": "planner-reviewer-claude-01",
"job_id": "1fb2cefa",
"verdict": "PASS",
"transport": "herdr",
"evidence": "review-tree",
"isolation": "worktree",
"verified_at": "2026-10-19T02:37:22Z"
}
@@ -0,0 +1,71 @@
# PR #62 리뷰 2회차 — Issue #60 Linux 확인 후속 (job 1fb2cefa)
- 리뷰어: planner-reviewer-claude-01
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261019T015801Z-05463c/1`
- identity: HEAD `6a37c0019baa31ee28b32a086e57b4c8239413c4`, tree `87013e7ef7f5239f1bf6ea38a2cbda214257be3c` 가 일치한다. 작업 트리는 clean 이고 `.mam` 은 없다.
- 범위: base `1d7b7da`..head 9커밋. 판정은 base..head 전체에 대한 것이다. 1회차 이후 바뀐 것은 6a37c00 하나이고, `opencode.py`·`status.sh`·`reconcile.sh`·`test_issue60_linux_fixes.py` 4개 파일만 바뀌었다.
- 1회차(@6a4e385)의 확인은 6a37c00 이 건드리지 않은 코드라 그대로 유효하다. 1회차에 확인한 것: 임시 DB 에서 `_ro_connect` 세 갈래, `_DB_PATHS` 키, `auth-ok` CLI, stop 문구.
- 환경: macOS, Python 3.9.6, sqlite 3.51.0. 실제 opencode 데이터는 `ls`·`grep -c`·`ps`·`lsof` 로만 봤다.
## 결론
1회차의 공통 차단(B1)과 codex·agy 지적이 모두 해결됐고, 새 회귀는 찾지 못했다. base..head 전체를 병합 가능으로 판단한다.
## 1회차 지적별 확인
**B1 (clean 상태 5개가 macOS 에서 실패) — 해결.**
- `make("clean")` 이 close 뒤 남은 `-wal`·`-shm` 을 지운다(`tests/test_issue60_linux_fixes.py:158-162`). 1회차 사본에서 내가 검증한 수정과 같다.
- 검증 경로에서 이 모듈이 모두 통과한다(아래 실행 결과).
- 뮤테이션 i1(immutable 분기를 `mode=ro` 로)이 이제 이 Mac 에서 5 failed 로 잡힌다. 1회차에는 fixture 가 먼저 실패해서 이 뮤테이션이 가려졌었다.
**안내 문구 패턴이 사용자가 친 `Ask anything…` 를 지움(codex) — 해결.**
- 패턴이 `Ask anything(?:…|\.\.\.) "[^"]*"?` 가 되어 따옴표 예시가 필수다(`opencode.py:80`).
- 두 fixture 에 넣어 직접 확인한 결과:
- `Ask anything…`, `Ask anything...` 는 친 그대로 읽힌다.
- 홈 화면 원본, 잘린 예시, `Ask anything… "` 는 빈 입력으로 읽힌다.
- 회귀 테스트 2개가 추가됐다(`:84-85`). 뮤테이션 p1(예시를 다시 선택 사항으로)은 4 failed 로 잡힌다.
**auth_ok 가 실행 불가한 `~/.opencode/bin/opencode` 도 인정(codex) — 해결.**
- 이제 `isfile and access(X_OK)` 로 본다(`opencode.py:322-323`).
- 권한 644 파일과 디렉터리는 모두 False 로 고정됐다(`test_a_home_install_that_cannot_run_is_not_enough`). 뮤테이션 a1(`exists` 로 되돌림)은 1 failed 로 잡힌다.
- `shutil.which` 가 이미 실행 가능 여부를 보므로 PATH 쪽은 그대로다.
**status.sh·reconcile.sh 의 `sqlite3.connect(odb)`(agy) — 해결.**
- status 의 opencode 두 블록(`status.sh:115-117`, `:316-318`)과 reconcile drift C(`reconcile.sh:953-955`)가 `_ro_connect` 를 쓴다.
- 두 스크립트는 lib_py 를 이미 import 하는 경로에서 돌기 때문에 import 가 된다(status 는 `env_python`/PYTHONPATH, reconcile 은 `env_python`).
- status 블록에서 `import sqlite3` 를 지웠지만 영향이 없다. 같은 함수의 다른 블록(hermes `:90`, codex `:132`)은 각자 `import sqlite3` 를 한다.
- 행동 확인: git archive 사본에 임시 e2e 를 넣었다. 순서는 이렇다.
1. mam_sandbox+mock 에서 create → 자식 세션 추가 → `journal_mode=wal` → 사이드카 삭제(Linux 의 닫힌 상태)
2. `reconcile.sh --once --emit-diff` → `status.sh --json` 과 plain status
- 결과(head): rc 는 모두 0. 부모 id 로 고정됐고 `resume_state == "yes"` 이다. DB 디렉터리는 실행 전후 모두 `['opencode.db']` 였다.
- 대조(두 스크립트를 `sqlite3.connect(odb)` 로 되돌린 사본): reconcile 뒤 `opencode.db-shm`·`opencode.db-wal` 이 생겨 같은 테스트가 실패했다.
- 참고: mock opencode 가 만드는 DB 는 WAL 모드가 아니다. 그래서 WAL 로 바꾸지 않으면 이 차이가 드러나지 않는다.
## 테스트 실행 (검증 경로)
- 조건: `env -i`, 임시 HOME, `XDG_DATA_HOME=<scratchpad>`, basetemp 는 scratchpad, `-p no:cacheprovider`, `PYTHONDONTWRITEBYTECODE=1`.
- 대상 12개 모듈: issue60_linux_fixes, issue60_opencode_v2, a4, tier1, tier2, issue31, issue55, issue57, issue21_worktree_prereqs, issue50_dispatch, issue30_stall_watch, c1_tui_readiness.
- 결과: **463 passed, 2 skipped, 실패 0**, 16분.
- skip 1: `/proc` 전용 테스트.
- skip 2: PATH 에 opencode 가 없어 건너뛴 실바이너리 계약 테스트(1회차·이전 이슈에서 따로 확인함).
- 부작용 확인:
- scratchpad XDG 디렉터리는 실행 뒤에도 비어 있었다.
- 실제 `~/.local/share/opencode` 의 목록은 전후가 같았다.
- 실제 log 의 `debug` 줄 수는 2 → 2 였다.
- 남은 opencode 프로세스는 사용자 서비스 하나뿐이다(pid 23630, 시작 08:13, 이 잡 이전).
- #55 가드 오탐은 없었다.
- 린트:
- `git diff --check 1d7b7da..HEAD` 는 깨끗하다.
- 바뀐 Python 은 3.9 로 파싱된다.
- `status.sh`·`reconcile.sh` 는 `/bin/bash -n`(3.2)을 통과한다.
## 비차단
- **N1.** 새 `test_status_and_reconcile_open_the_database_read_only` 는 소스 문자열만 센다. `sqlite3.connect(odb, timeout=…)` 같은 변형은 지나간다. 위의 임시 e2e(WAL DB, 사이드카 없음 → reconcile·status → 목록 불변)를 tier2 opencode e2e 에 붙이면 행동으로 고정할 수 있다.
- **N2.** 1회차 비차단은 의도된 완화로 그대로 남아 있다(브리프에도 기록됨):
- immutable 경쟁 창(ms 수준)
- `_DB_PATHS` 의 프로세스 수명 캐시
- create 사전 검사의 완화
- **N3.** 아주 좁은 화면에서 예시의 여는 따옴표까지 잘리면 `Ask anything…` 만 남는다. 이때는 친 초안으로 읽힌다(안전한 쪽). 실제 캡처로는 확인하지 않았다.
[VERDICT: PASS]
@@ -0,0 +1,51 @@
# 리뷰 리포트 — PR #59 (Issue #31, claude purge 잔여물) / job 207c65b8
- 리뷰어: planner-reviewer-claude-01 (Opus 5.5)
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261016T003524Z-a51548/1`
- identity: HEAD `6f4f40a4adb2115d5997dffd31fb6473f05cab68`, tree `f4d9422ad0c1d616b5f37065f3e96b34c28f78d8` — 일치. 트리 clean, `.mam` 없음(테스트 실행 후에도 동일).
- 범위: base 26e2c43 .. head 6f4f40a (13 커밋, 12 파일)
## 요약
설계와 대부분의 구현은 이 Mac에서 맞게 동작한다. 실제 `~/.claude` 구조, BSD `ps -o lstart=`, `/tmp → /private/tmp` 스크래치패드 레이아웃이 어댑터 가정과 일치하고, 다른 세션·공유 파일·심볼릭 링크 너머는 지우지 않았다. 다만 **삭제 범위 가드(`_plausible_id`)가 UUID가 아닌 id를 거르지 못해, id `memory` 로 purge 하면 `projects/<key>/memory/`(Claude Code 자동 메모리 디렉터리)가 통째로 지워진다**(F1). 이 PR이 `<id>.jsonl` 삭제에서 `<id>` 디렉터리 rmtree 로 범위를 넓히면서 생긴 회귀이고, 브리프 중점 항목("잘못된 uuid 거부")에 해당하므로 NOT PASS 로 판정한다. 수정은 한 줄 수준이다.
## 차단 결함
### F1. 비-UUID id `memory` 가 자동 메모리 디렉터리를 지운다
- 위치: `.agents/skills/lib_py/agents/base.py:89-92` (`_plausible_id`: 빈 문자열·점만·`/`·`..`·`*` 만 거부), `.agents/skills/lib_py/agents/adapters/claude.py:262-268` (`base = projects/<key>/<uuid>`, 디렉터리면 `_remove` → `shutil.rmtree`), `claude.py:287-289` (`has_artifacts` 가 같은 목록을 써서 `purge auto` 도 claude 로 해석).
- 실제 이 Mac 의 `~/.claude/projects/<key>/` 에는 세션 디렉터리와 나란히 `memory/` 가 있다(읽기만 확인: `projects/-Users-godopu16-PuKi-laa-canary-projects-multi-agent-mux/memory/`). 세션 id 와 같은 층에 있는 유일한 비-UUID 이름이다.
- 재현 1(어댑터 직접, 샌드박스 HOME/CLAUDE_PROJECT_DIR/CLAUDE_CODE_TMPDIR/TMPDIR, `/usr/bin/python3` 3.9.6):
`has_artifacts("memory") → True`, `purge_artifacts("memory") → [.../projects/<key>/memory, .../file-history/memory]`, `MEMORY.md survives: False`.
- 재현 2(CLI): `python -m lib_py.agents purge claude memory <ws>` → 이 호스트에서는 다른 프로세스 29개의 argv 에 "memory" 가 들어 있어 우연히 in-use(rc 4)로 거부됐다. argv 에 "memory" 가 없는 호스트를 흉내 내려고 `ps` 출력에서 그 줄만 거르는 스텁을 PATH 앞에 두면 rc 0, `"purged": [".../projects/<key>/memory"]`, `memory survives: False`.
(스크립트: `/private/tmp/claude-501/-Users-godopu16-PuKi-laa-canary-projects-multi-agent-mux/8cb38cbf-0d61-4d78-8c7e-065e409ce13d/scratchpad/j207/probe_mem.py`)
- 도달 경로: 스킬 진입점은 안전하다 — `orc_onboard.sh:88-95` 은 정규식으로 UUID 를 강제하고, `stop_session.sh` 의 `PURGE_UUID` 는 `find_workspace_uuid` 가 `<id>.jsonl` 의 sessionId 일치로 검증한 값이다. 위험한 경로는 직접 쓰는 `lib_py.agents purge`/어댑터 API(VERSIONS.md 에 문서화)다. 그러나 이 PR 이전에는 같은 입력이 존재하지 않는 `memory.jsonl` 만 겨냥해 무해했다. 지워지는 것은 복구할 수 없는 사용자 메모리이고, 테스트(`tests/test_issue31_purge_residual.py:242` 의 `bad` 목록)도 이 경우를 다루지 않는다.
- 제안: claude 어댑터의 `_owned_paths`/`residual_artifacts`(또는 `purge_artifacts`·`has_artifacts` 입구)에서 정규 UUID(`[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}`, 대소문자 무시)만 받게 하고, `bad` 목록에 `"memory"` 와 UUID 가 아닌 평범한 이름을 추가하는 회귀 테스트. 다른 어댑터는 이름 스캔(`scan_names`)·보고만 하므로 공용 `_plausible_id` 는 그대로 둬도 된다.
## 확인한 사항 (정상)
1. **BSD `ps -o lstart=`**: 이 Mac 출력은 `Fri Oct 16 08:25:14 2026` (LC_ALL=C 와 기본 로캘 동일). `" ".join(out.split())` + `%a %b %d %H:%M:%S %Y` 로 파싱된다. 실제 살아 있는 claude 4개의 `sessions/<pid>.json` 에서 `startedAt − lstart` = 0.69 / 1.14 / 1.93 / 2.43 s → 모두 live 판정(`lstart <= startedAt+2`). 샌드박스 `sleep` 프로세스로: 방금 쓴 startedAt → in-use rc 4(`--wait 2` 로 2.2 s 대기 후), purge CLI rc 4, jsonl 보존; 프로세스 종료 후 rc 0. startedAt 을 1시간 전으로 둔(pid 재사용) 경우 → free, 그 세션 파일만 삭제. `ps` 실패·빈 출력·파싱 실패·`startedAt` 없음 → live(`claude.py:66-74`).
2. **tmp 루트**: TMPDIR=`/var/folders/.../T/` 아래에는 `claude-501` 이 없고, 실제 스크래치패드는 `/private/tmp/claude-501/<key>/<uuid>/{scratchpad,tasks}` 다. 후보에 항상 `/tmp/claude-<uid>` 가 들어가고(`claude.py:41`) realpath 로 `/private/tmp` 와 중복 제거된다. `<key>` 는 계산하지 않고 루트 아래 모든 키를 나열해 `<k>/<uuid>` 만 고르므로 키 규칙 차이에 영향받지 않는다. 샌드박스에서 CLAUDE_CODE_TMPDIR·TMPDIR 양쪽 루트의 `<uuid>` 만 지워졌다.
3. **실제 `~/.claude` 구조**(읽기만): `sessions/<pid>.json`(int `pid`, ms `startedAt`, `sessionId`) + `<pid>.<64hex>.key`, `jobs/<8hex>/state.json`(sessionId[:8] = 디렉터리명), `file-history/<uuid>/`, `session-env/<uuid>/`, `projects/<key>/<uuid>/{tool-results,…}` — 어댑터 가정과 일치.
4. **삭제 범위**(샌드박스, `/usr/bin/python3`): 같은 jobs 접두사의 다른 세션, 다른 세션의 sessions json·key, 같은 pid 접두사이지만 64hex 가 아닌 `.key`, `history.jsonl`, 다른 uuid 의 file-history·jsonl·스크래치패드가 모두 보존됐다. `session-env/<U>/link → 외부`, 스크래치패드 안의 `linkout → 외부` 심볼릭 링크는 링크만 지워지고 대상 파일은 남았다. 재실행 → rc 5(남은 것 없음). `""`/`.`/`..`/`...`/`a/b`/`*`/`../x` → 아무것도 지우지 않음(rc 1 또는 5).
5. **stop exit 4 계약**: 게이트(`stop_session.sh:285-300`)는 레지스트리 잠금 밖, YAML 쓰기 전에 실행되고 0 이외는 모두 exit 4 + cancelled 이벤트. `_delegate_py_bin` 가 `/usr/bin/python3` 로 떨어져도 `-m lib_py.agents in-use` 는 paho 없이 동작한다(직접 확인). bash 3.2 `-n` 통과, `inuse_rc=0; … || inuse_rc=$?` 는 `set -u` 에서 안전.
6. **residual 보고**: purge 후 `residual_artifacts` 가 빈 목록이면 `residual: none (agent: …)`. 실제 `~/.claude` 에 대한 이름 스캔(읽기 전용, 가짜 uuid)은 0.02 s.
7. **Python 3.9 / bash 3.2**: 변경된 .py 8개 모두 `feature_version=(3,9)` 로 파싱되고, 프로브는 전부 `/usr/bin/python3` 3.9.6 으로 실행됐다(registry 가 모든 어댑터를 import). `lib.sh`·`stop_session.sh` 는 `/bin/bash` 3.2.57 `-n` 통과.
8. **문서·skill_code_blocks.json**: stop SKILL.md 의 "purge 는 행을 지운다"·exit 4·삭제 목록이 코드와 맞고, `test_issue42_skill_blocks.py` 통과(sha·passthrough 라인 갱신 정합).
9. **codex·grok·opencode residual**: 이름 스캔 + `mode=ro` SQLite 조회만 하므로 쓰기 없음.
## 테스트
- 검증 경로에서 `env -i …`(HERDR_*·WORKSPACE_ROOT·MAM_*·DELEGATE_JOB_LOGS_DIR·CLAUDE_CONFIG_DIR 미설정), `--basetemp` 는 스크래치패드, `PYTHONDONTWRITEBYTECODE=1 -p no:cacheprovider`:
`test_issue31_purge_residual, test_issue10_13_purge, test_a4_adapter_contract, test_issue42_skill_blocks, test_issue55_agent_homes, test_orc_onboard, test_uuid_target` → **188 passed, 1 skipped** (276 s). 실행 후 트리 clean.
- 사전 수정 대조: `git archive HEAD` 복사본에서 제품 파일 8개를 base 26e2c43 로 되돌리고 `test_issue31_purge_residual.py` 실행 → **24 failed, 8 passed**. 통과한 8개는 "다른 세션을 건드리지 않음"·"잘못된 uuid 거부"처럼 이전 코드에서도 성립하는 안전성 테스트로, 기대한 결과다. stop 게이트 테스트(`test_stop_purge_refuses_while_the_claude_process_lives`)와 pid 재사용 테스트도 사전 코드에서 실패한다.
## 비차단 메모
- N1. DST: `time.mktime(time.strptime(...))` 는 `tm_isdst=-1` 이라 서머타임이 끝나는 겹치는 1시간에는 lstart 가 1시간 늦게 해석될 수 있고, 그러면 살아 있는 세션을 stale 로 볼 수 있다(fail-open). 이 Mac 은 KST(서머타임 없음)라 영향 없음. 실제 세션 파일에 `procStart`(UTC 문자열, `pidDomain: "darwin"`)가 있으니 이를 쓰거나 비교를 UTC 로 하는 것도 고려할 만하다.
- N2. stop 의 exit 4 메시지(`stop_session.sh:296`)는 `~/.claude/sessions/` 만 가리킨다. CLAUDE_CONFIG_DIR 을 쓰면 위치가 다르고, argv 에 uuid 를 담은 아무 프로세스(에디터·grep·위임 래퍼)도 거부 사유가 된다. orc_onboard SKILL.md 처럼 `ps -axww | grep <uuid>` 안내를 함께 두면 좋다.
- N3. SKILL.md exit 4 행의 "(nothing was changed)" — 프로세스 게이트는 herdr kill 이후에 돌므로 세션은 이미 내려가 있을 수 있다(레코드는 그대로). 기존 herdr 게이트 문구와 같은 뉘앙스이지만 "records preserved" 가 더 정확하다.
- N4. stop 이 이제 모든 에이전트에 in-use 게이트를 적용한다. agy 처럼 CLI 종료 뒤에도 다른 프로세스가 DB/락을 열어 두는 구성이면 이전에는 purge 되던 것이 exit 4 로 바뀐다. 의도된 fail-closed 이지만 실제 agy 로는 확인하지 못했다(브리프상 실제 에이전트 실행 금지).
- N5. 테스트는 하드코딩된 `/tmp/claude-<uid>` 후보를 격리하지 못한다(실제 `/private/tmp/claude-501` 을 나열). 무작위 uuid 라 실제로 지워지는 것은 없지만, `_tmp_roots` 를 테스트에서 패치할 수 있게 하면 더 안전하다.
[VERDICT: NOT PASS]
@@ -0,0 +1,44 @@
# 리뷰 리포트 — Job 24b1f07f (Issue #17 후속: 테스트의 상속 WORKSPACE_ROOT, 6b00b6e..d198dd5)
- 리뷰어: planner-reviewer-claude-01 (claude)
- 리뷰 identity: loop-20261008T133858Z-9e7f5b / base 6b00b6e / head d198dd5 / tree 114ec21 — 검증 경로에서 `git rev-parse HEAD 'HEAD^{tree}'` 일치, 미커밋 변경 없음
- 범위: 커밋 1개(d198dd5), tests/test_issue17_item4_decisions.py 만(+36/−2). 제품 코드(.agents/skills)·버전·VERSIONS 변경 없음(브리프대로).
## 결론 요약
할 일 1·3·4 가 요구대로 반영됐고, 새 회귀 테스트는 수정 전 `_env` 에서 실제로 실패한다(센티널에 `.mam/shim/herdr` 생성). 할 일 2(다른 테스트 조사)는 이 PR 에 코드 변경이 없는데, 내가 직접 grep 한 결과로도 **같은 결함(다른 워크스페이스에 쓰기)은 conftest 가 이미 프로세스 수준에서 막고 있어** 추가 수정 대상이 없다 — 다만 남는 두 파일(§3)은 creator 리포트의 표에 있어야 한다. 차단 결함 없음.
## 1. 할 일 1 — `_env` (tests/test_issue17_item4_decisions.py:224-233)
- 상속 제외 목록에 `WORKSPACE_ROOT` 추가(:229), `env.update(... WORKSPACE_ROOT=str(tmp_path) ...)`(:231) 로 테스트별 임시 경로를 명시. "제거"가 아니라 "지정"을 고른 이유는 주석(:225-227)에 있고 맞다: lib.sh:26 은 `WORKSPACE_ROOT` 가 비어 있으면 `$SKILL_DIR/../..` = **체크아웃 자신**을 쓰므로, 제거만 하면 래퍼를 source 하는 헬퍼 4곳(_run_both :246/:372/:561/:586 등)이 체크아웃의 `.mam/shim/herdr` 를 다시 쓴다. 지정하면 shim 이 tmp_path 아래에 생긴다(회귀 테스트가 `(work/.mam/shim/herdr).is_file()` 로 고정, :498).
- `extra` 로 넘기는 명시 WORKSPACE_ROOT(:692 의 wait-rc 테스트)는 `env.update(extra)` 가 뒤에 오므로 그대로 이긴다 — 의도적 지정 테스트가 깨지지 않는다.
## 2. 할 일 3 — 회귀 테스트 test_c_helpers_write_nothing_to_an_inherited_workspace (:477-498)
- 센티널 디렉터리를 `WORKSPACE_ROOT` 로 monkeypatch 한 뒤 `_run_both`(옛·새 양쪽)와 래퍼 직접 source 를 돌리고, 센티널 아래 파일 없음·체크아웃 shim mtime 불변·셸이 보는 `$WORKSPACE_ROOT` 가 테스트 tmp 임·shim 이 tmp 에 생김을 단언한다.
- **수정 전 코드에서 실패 확인**: HEAD 의 테스트 파일에서 `_env` 두 줄만 base(6b00b6e) 판으로 되돌린 스크래치 사본 → `AssertionError: written into the sentinel`, `['.mam', '.mam/shim', '.mam/shim/herdr'] == []` (1 failed). 현재 코드에서 통과.
- conftest 의 `pytest_configure` 는 다른 체크아웃을 가리키는 WORKSPACE_ROOT 를 수집 전에 지우므로(tests/conftest.py:139-148), 이 테스트가 monkeypatch 로 다시 설정하는 것이 맞는 설계다(지우지 않으면 센티널이 보이지 않는다).
## 3. 할 일 2 — 같은 결함이 다른 테스트에 있는가 (직접 grep)
- **conftest 가 막는 범위**: `pytest_configure`(conftest.py:139-148)가 AGENT_SESSIONS_YAML·MAM_JOBS_DIR·DELEGATE_JOB_LOGS_DIR 을 항상, `WORKSPACE_ROOT` 는 **realpath 가 이 체크아웃이 아닐 때만** os.environ 에서 지운다(주석 :122-124: 이 체크아웃을 가리키면 "lib.sh 가 어차피 유도하는 값"). 따라서 어떤 테스트든 os.environ 을 복사해 lib.sh 를 돌려도 **다른 워크스페이스에는 쓸 수 없다**. 남는 경우는 (i) 체크아웃 자신의 shim 재작성, (ii) 테스트가 monkeypatch 로 다시 설정한 값, (iii) pytest 밖에서 헬퍼를 직접 돌리는 경우(PR #51 리뷰 때의 사고는 리뷰어의 수동 탐침, 즉 (iii)).
- **이 파일이 "빠진" 이유**: 빠진 것이 아니라 (i)(iii) 에 노출돼 있었다 — `_env` 가 WORKSPACE_ROOT 를 남겨 둔 채 래퍼→lib.sh 를 source 했으므로, 스위트 실행 중에는 체크아웃 자신의 shim 을 매번 다시 썼고, 수동 실행에서는 상속값 그대로 썼다. 이번 수정이 둘 다 막는다.
- os.environ 을 복사하고 lib.sh/래퍼를 돌리는 테스트 파일 36개를 grep 했다. `mam_sandbox` 를 쓰는 파일은 fixture 가 `WORKSPACE_ROOT=tmp_path` 를 설정하므로(conftest.py:215) 복사본이 sandbox 를 물려받아 안전하다. fixture 없이 lib.sh 를 실제로 source 하는 파일은 둘뿐이며, 둘 다 (i) 에만 해당한다:
| 파일:라인 | 동작 | 판단 |
|---|---|---|
| tests/test_b19_headless_reconcile_fixes.py:326-329, :366-369, :401-402 | `source "$SKILL_DIR/lib.sh"` 하는 스크립트를 SKS_* 만 뺀 os.environ 으로 실행 | 다른 체크아웃은 conftest 가 지움; 이 체크아웃이면 자신의 shim 재작성(conftest 가 명시적으로 허용하는 경우). 고치지 않아도 되나 표에 있어야 함 |
| tests/test_version_consistency.py:30 | `bash -c 'source lib.sh …'` 를 env 지정 없이 cwd=REPO_ROOT 로 실행 | 위와 같음 |
| tests/test_deploy_freshness.py, test_deploy_layout.py | lib.sh 를 복사·읽기만, source 안 함 | 해당 없음 |
- 제품 코드 변경 필요 여부: 없음. conftest 한 곳에서 "이 체크아웃" 경우까지 지우는 방법은 lib.sh 가 어차피 같은 값을 유도하므로 효과가 없고(주석 :122-124 의 근거), 체크아웃 shim 재작성을 피하려면 각 헬퍼가 tmp 를 지정해야 한다 — 이 PR 의 방식이 맞다.
## 4. 할 일 4 — 실행 (검증 경로, 워크트리 .venv Python 3.9.6, `env -i` 로 HERDR_*·MAM_*·MQTT_* 제거)
| 실행 | 결과 |
|---|---|
| (a) WORKSPACE_ROOT 없이: test_issue17_item4_decisions, test_issue33_budget, test_issue5_phase1_delegation, test_tier1_unit | **443 passed / 0 failed** (478 s) |
| (b) WORKSPACE_ROOT=<워크트리 밖 센티널>: test_issue17_item4_decisions | **290 passed / 0 failed** (319 s); 실행 뒤 센티널 **비어 있음**(`find` 출력 없음) |
| 회귀 테스트, 수정 전 `_env` 사본 | 1 failed(센티널에 .mam/shim/herdr) |
- 본 체크아웃 `.mam/shim/herdr` mtime 실행 전후 동일(1791469013). 실제 .mam/jobs 항목 수 불변. 브로커 bind 제약 없음.
- (b) 에서 센티널은 realpath 가 체크아웃이 아니라 conftest 가 지우므로, 대부분의 테스트는 conftest 로, 회귀 테스트는 `_env` 로 보호되는 셈이다 — 둘 다 통과했으니 브리프 (b) 의 목적(어느 층이든 센티널에 쓰지 않음)은 충족된다.
## 5. 이식성·비차단
- 변경은 Python 테스트 코드뿐(3.9 문법, `stat().st_mtime_ns`, `Path.rglob`). bash 3.2·macOS 영향 없음.
- 비차단: §3 표의 두 파일이 creator 리포트의 "고치지 않은 후보" 표에 없다면 추가를 권한다. 새 관찰 없음.
[VERDICT: PASS]
@@ -0,0 +1,14 @@
{
"loop_id": "loop-20260921T150212Z-97d845",
"base_sha": "6fc9194f896eb2e13e123b8ebe7a9089b629764d",
"head_sha": "1e826515cc2a07ea1f8da0ff1259e1ea4dde86fd",
"tree_sha": "4fd82ad1c2682d12c3f5114818f97291af28a032",
"review_commit": "1e826515cc2a07ea1f8da0ff1259e1ea4dde86fd",
"reviewer": "planner-reviewer-claude-01",
"job_id": "293e905f",
"verdict": "PASS",
"transport": "herdr",
"evidence": "review-tree",
"isolation": "worktree",
"verified_at": "2026-09-21T15:25:44Z"
}
@@ -0,0 +1,139 @@
# 🔍 Review Report — PR #32 병합 전 독립 리뷰 (job `293e905f`)
- **Reviewer**: `planner-reviewer-claude-01` (role: planner,reviewer) · 2026-09-22
- **대상**: PR #32 `fix/issue-25` **@ `1e82651`**, base `main 6fc9194` (v4.7.0). 5커밋, 4파일 `+486/−31`.
- **검증 경로**: `/var/folders/.../T/mam-review/loop-20260921T150212Z-97d845/1` (브리프가 지정한 리뷰 워크트리). 실제로 거기서만 검증했다.
- **identity 확인**: 검증 경로의 `HEAD = 1e826515cc2a07ea1f8da0ff1259e1ea4dde86fd`, `HEAD^{tree} = 4fd82ad1c2682d12c3f5114818f97291af28a032` — 브리프의 `head`·`tree` 와 **일치**한다(항목 5 identity 가 실제로 작동함을 이 잡 자체가 보여준다).
- **안전 규칙 준수**: 검증 경로·소스 워크트리(`multi-agent-mux-wt/pr-32`)·본 체크아웃(`main 6fc9194`) 모두 수정·커밋·checkout 없음. 실험은 전부 scratchpad 임시 디렉터리(uuid4 이름)에서 했고, pytest 는 `--basetemp` 을 검증 경로 밖으로 주었다. 실제 `.mam`·데몬·세션 조작 없음, push·merge·PR 상태 변경 없음, purge·scrub 실행 없음.
## 결론 요약
**병합을 막을 결함은 없다.** 핵심 변경(암묵적 계획서 로드 제거, `--plan-file` 명시 재사용, 네 지점 공통 계획 텍스트 검사)은 코드·문서·테스트가 일치하며, 새 테스트 17건 중 **13건이 수정 전 소스에서는 실패**한다(직접 확인). 비차단 지적 3건(E1–E3)을 남긴다.
## 1. 암묵적 로드 제거 (항목 1)
| 확인 | 결과 |
|---|---|
| Python 러너 | `runner.py` 에서 `glob` import 가 사라졌고, 남은 `glob` 사용처는 `find_report` 의 `report-final.md` 탐색(`:568`) 하나뿐이다. `.agents/reports/<planner>/plan-*.md` 를 읽는 코드가 없다 |
| 레거시 셸 러너 | `run_loop.sh` 의 `EXISTING_PLAN_FILE`/`ls -t .../plan-*.md` 블록이 통째로 사라지고 `log_info "No plan loaded; the Creator plans by itself."` 만 남았다 |
| 다른 경로 | 저장소 전체에서 `plan-*.md` 를 읽는 코드는 없다(승격 쪽 **쓰기**만 `runner.py:1368-1372` 에 남는다 — 정상) |
| planner 자동 선택 | `resolve_planner_session`(`run_loop.sh:386`)·`resolve_planner_session` 파이썬 대응은 남아 있으나, 그 결과는 **plan 모드의 위임 대상과 승격 경로**에만 쓰인다. 자기계획 모드에서 planner 이름으로 파일을 찾는 코드는 없다 |
## 2. `--plan-file` (항목 2) — 실측
검증 경로의 러너를 임시 제어 루트(`WORKSPACE_ROOT=<tmp>/ctl`)로 직접 돌려 확인했다.
| 입력 | 결과 |
|---|---|
| 없는 파일 | `[✗] --plan-file <절대경로>: not a readable file with plan text in it.` · **exit 1** |
| 빈 파일 / 공백만 있는 파일 | 같은 메시지 · exit 1 |
| 디렉터리 | 같은 메시지 · exit 1 (`is_file()` 로 걸린다) |
| 읽기 권한 없음(`chmod 000`) | 같은 메시지 · exit 1 (`OSError` → `None`) |
| `--plan` 과 함께 | `ERROR: --plan-file cannot be combined with --plan.` + 사용 예 2줄 · exit 1 |
| 상대경로 | **제어 루트 기준**. 호출 cwd(`<tmp>/elsewhere`)에 `plans/p.md` 가 실제로 있어도 러너는 `<ctl>/plans/p.md` 를 찾고 실패한다 — #25 의 "다른 디렉터리의 동명 파일을 집어오는" 사고를 구조적으로 막는다 |
| 공백 있는 경로(`my plans/p.md`, 상대) | 정상 로드: `Using the plan given with --plan-file: <ctl>/my plans/p.md (modified ... UTC)` (셸을 거치지 않으므로 인용 문제 없음) |
| 절대경로·`~` | `Path.__truediv__` 가 절대경로를 그대로 두고 `expanduser()` 가 앞서 적용된다(P3d 가 고정) |
| `--review-only` 와 함께 | 경고 후 무시(`log_warn`), `current_plan` 을 채우지 않는다 |
| 레거시 셸(`MAM_LOOP_PY=0`) | `ERROR: --worktree/--base/--review-only/--review-isolation/--remote-review/--plan-file require the Python loop (MAM_LOOP_PY=1)` · exit 1 — **동등 구현 대신 명시적 거부**이며, 기본 경로(`MAM_LOOP_PY=1`)는 `exec "$_py" -m lib_py.loop "${MAM_LOOP_ARGV[@]}"`(`run_loop.sh:262`)로 원본 argv 를 그대로 넘기므로 공백 경로도 안전하다 |
읽기가 **잡을 하나도 만들기 전**(parse_args 단계)에 끝나는 것도 확인했다 — 실패 시 레지스트리에 아무 흔적이 없다.
## 3. 계획 텍스트 검사 (항목 3)
`_plan_text()`(`runner.py:303-317`)가 "읽을 수 있는 일반 파일 + 공백 제외 내용 있음"을 단일 판정으로 만들고, **파이썬 러너의 네 지점이 모두 이를 쓴다**: `--plan-file`(`:265`), `--plan` 초기 계획서(`:1025`), 논의 턴 refine(`:1054`), 리뷰 에스컬레이션 refine(`:1284`).
- 빈 초기 계획서 → `log_error` 후 **return 1**. 이때 이미 만든 잡은 `release_guard`/정리 경로가 담당하고, 자기계획으로의 조용한 격하가 사라졌다.
- 빈 refine → 경고 + **이전 계획 유지**. 그래서 "갱신된 다음 계획서에 입각하여…" 프롬프트가 빈 계획을 싣는 일이 없다.
- 에스컬레이션 경로(`[ESCALATE: PLANNER]`)는 `if self.plan_mode and complex_fix`(`:1275`)로 **plan 모드에만** 걸린다. `--plan-file` 로 계획을 실은 실행에서 ESCALATE 판정이 나오면 재계획 없이 일반 수정 라운드로 간다 — 샌드박스에서 직접 구동해 확인했다(Planner 위임 0건, 수정 프롬프트는 "리뷰어들이 지적한 다음 피드백…"). SKILL.md:31 의 "수정 라운드에는 리뷰 피드백만" 서술과 **일치**한다. 다만 이 동작 자체는 E2 로 남긴다.
## 4. 체크포인트 (항목 4)
`.mam/loop-checkpoint.json` 의 `current_plan` 은 **쓰기 전용**이다. `checkpoint.load_checkpoint()` 는 정의되어 있으나 `runner.py` 어디에서도 호출하지 않는다(`grep 'checkpoint\.'` → `save_checkpoint`·`remove_checkpoint` 뿐). 따라서 본 체크아웃 체크포인트에 남아 있는 Issue #6 계획 텍스트가 다음 루프나 재개에서 되살아날 경로는 없다. 계획은 매 실행마다 `--plan-file` 또는 `--plan` 으로만 들어온다.
## 5. 옵션 없을 때의 동작 변화 (항목 5)
- 자기계획 프롬프트(`runner.py:1095`)와 승격 경로(`:1367` `if self.plan_mode and self.current_plan and self.plan_job_id`)는 그대로다. 즉 `--plan-file` 로 실은 계획서는 **승격되지 않는다**(승격은 plan 모드의 planner 산출물만) — 옳은 구분이다.
- 바뀐 것은 "planner 리포트 디렉터리에서 최신 plan-*.md 를 끌어오던" 동작 하나뿐이다. 그 외 프롬프트 문구·옵션 기본값·종료 코드는 동일하다.
- 기존 스위트 정합은 §6 에서 실측했다.
## 6. 테스트 (항목 6)
### 6-1. 검증 경로에서의 실행
```
cd <검증 경로> && PYTHONDONTWRITEBYTECODE=1 <제어 루트 .venv/bin/python> -m pytest -p no:cacheprovider -q -rfEs \
--basetemp=<검증 경로 밖> tests/test_issue25_plan_reuse.py tests/test_loop_cli.py tests/test_issue5_phase3_loop.py \
tests/test_issue21_loop_b.py tests/test_issue21_phase2_loop_a.py tests/test_issue21_acceptance.py
→ 2 failed, 108 passed in 220.98s (skipped 0)
```
**실패 2건은 PR 회귀가 아니라 리뷰 워크트리에 `.venv` 가 없어서 생긴 환경 문제다.**
| 실패 | 원인 | 확인 |
|---|---|---|
| `test_loop_cli.py::test_creator_parses_and_reaches_session_check` | `run_loop.sh` 를 하위 프로세스로 띄우는데, `REPO_ROOT`(= 검증 경로)에 `.venv` 가 없어 `_py` 가 시스템 python 으로 떨어지고 `RuntimeError: Failed to eager load lib_py.loop.autocomplete: No module named 'paho'` 로 죽는다(`run_loop.sh:253`) | `AGENT_PYTHON_BIN=<제어 루트 .venv/bin/python>` 만 주고 둘을 다시 돌리면 **2 passed in 0.34s** |
| `test_issue5_phase3_loop.py::test_p3_02_run_loop_wrapper_delegates_to_python_default` | 동일 | 동일 |
두 시험 모두 PR 이 건드리지 않은 파일이며, 실패 메시지에 계획서·`--plan-file` 은 등장하지 않는다.
### 6-2. 수정 없이는 실패하는가 — 확인함
검증 경로 사본을 만들고 `runner.py`·`run_loop.sh`·`SKILL.md` **세 파일만 base `6fc9194` 판으로 되돌려** 새 테스트를 돌렸다(검증 경로 자체는 건드리지 않았다).
```
13 failed, 4 passed in 1.51s
FAILED P1, P2, P3b, P3c, P3d, P5, P9[empty], P9[whitespace], P10, P11, P7, P7b, P8
```
통과한 4건(P3·P4·P5b·P6)은 옵션 없는 일반 CLI 오류·plan 모드 정상 경로라 수정과 무관한 것들이다. 즉 새 테스트는 이 변경을 정확히 겨냥한다.
### 6-3. 격리
| 항목 | 결과 |
|---|---|
| 실제 `.mam/jobs` 전후 | **차이 0** |
| 실제 `.agents/reports` | 새 항목 없음(테스트는 각자 `tmp_path` 안에 제어 루트를 만든다 — `ctl` 픽스처) |
| 검증 경로 tracked 파일 | 변화 없음(`git status --short` 클린, HEAD `1e82651` 유지) |
| 검증 경로 ignored | 내 실행이 `.mam/`(무시됨)을 만들었다. tracked 파일·커밋에는 영향이 없으며, 지우면 리뷰 트리를 수정하는 셈이라 그대로 두었다 → E3 |
| 실행 시간 | 새 파일 `test_issue25_plan_reuse.py` 는 전체 17건이 **1.5 s** 안에 끝난다(브로커·herdr 의존 없음, 모두 stub) |
## 7. 문서 (항목 7)
- 옛 서술("기존에 승격된 계획서가 있다면 이를 로드")은 `.agents`·`docs` 어디에도 남아 있지 않다(전수 grep).
- SKILL.md 의 사용법 문자열·CLI 표·Workflow 예시·Mermaid 다이어그램이 모두 `--plan-file` 기준으로 맞춰졌고, "계획서가 비어 있을 때" 절이 네 지점의 판정을 정확히 설명한다.
- 러너 usage 와 셸 usage 문자열이 동일하다(P7 이 이를 고정한다).
- 배포 사본: 저장소의 `.claude/skills/*` 는 `../../.agents/skills/*` **심링크**라 자동으로 동기화된다. 사용자 홈 `~/.claude/skills` 에는 mux 스킬 사본이 없다. 옛 문구를 가진 사본은 발견되지 않았다.
## 8. 병합 차단 여부 (항목 8)
버전 락스텝 bump 부재(`lib.sh:32`·`eager.py:16`·loop SKILL.md 모두 `4.7.0`, 오케스트레이터가 4.7.1 로 처리 예정) 외에 **병합을 막을 결함은 없다**.
| # | 비차단 지적 | 근거 |
|---|---|---|
| E1 | 레거시 셸 러너(`MAM_LOOP_PY=0`)의 plan 모드 읽기에는 빈 계획서 검사가 없다: `CURRENT_PLAN=$(cat "$PLAN_FILE")`(`run_loop.sh:501`). D13/D14 계약은 파이썬 러너에만 성립하는데 SKILL.md 의 "계획서가 비어 있을 때" 절은 이를 명시하지 않는다 | 후속: 한 줄 거부 또는 문서에 "Python 러너 기준" 명시 |
| E2 | `--plan-file` 로 계획을 실은 실행에서 `[ESCALATE: PLANNER]` 는 재계획 없이 일반 수정 라운드로 내려간다(`runner.py:1275` 가 `plan_mode` 로 게이트). 샌드박스 구동으로 확인(Planner 위임 0건). 이 게이트는 PR 이전부터 있던 것이고 SKILL.md 서술과도 모순되지 않지만, "계획서를 갖고 도는 루프인데 에스컬레이션이 무력화된다"는 점은 문서에 없다 | 문서 보강 권고 |
| E3 | 리뷰 격리 워크트리에는 `.venv` 가 없어, `run_loop.sh` 를 하위 프로세스로 띄우는 스위트는 `AGENT_PYTHON_BIN` 없이는 반드시 실패한다(§6-1). 또한 그 워크트리에서 pytest 를 돌리면 무시되는 `.mam/` 이 생긴다. **PR #32 가 아니라 Issue #21 항목 5 운영 쪽 후속**이다 | 리뷰 브리프의 테스트 명령에 `AGENT_PYTHON_BIN=<제어 루트 .venv/bin/python>` 를 포함하는 것을 권고 |
| E4 | `runner.py:1070` 의 `os.path.getmtime(self.plan_file)` 는 파싱 시점과 Phase 1 사이에 파일이 사라지면 예외로 죽는다(메시지 없는 트레이스백). 극히 좁은 경합 | 사소 |
## 9. 교차검증 가이드
```bash
RP=/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20260921T150212Z-97d845/1
PY=/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.venv/bin/python
git -C "$RP" rev-parse HEAD HEAD^{tree} # 1e8265... / 4fd82a... = 브리프의 head·tree
# --plan-file 실패 경로 (임시 제어 루트에서만)
C=$(mktemp -d); mkdir -p "$C/ctl" "$C/elsewhere/plans"; : > "$C/ctl/empty.md"; echo x > "$C/elsewhere/plans/p.md"
(cd "$C/elsewhere" && PYTHONPATH="$RP/.agents/skills" WORKSPACE_ROOT="$C/ctl" MAM_STATE_JSON='{"herdr_sessions":[]}' \
"$PY" -m lib_py.loop --creator c --task t --plan-file plans/p.md; echo "rc=$?") # ctl 기준 경로로 실패, rc 1
# 수정 없이는 실패하는지 (사본에서만)
S=$(mktemp -d); rsync -a --exclude .venv --exclude .git --exclude .mam --exclude nats-docker "$RP/" "$S/"
for f in .agents/skills/lib_py/loop/runner.py .agents/skills/multi-agent-mux-loop/scripts/run_loop.sh \
.agents/skills/multi-agent-mux-loop/SKILL.md; do git -C "$RP" show 6fc9194:"$f" > "$S/$f"; done
(cd "$S" && PYTHONDONTWRITEBYTECODE=1 "$PY" -m pytest -q -p no:cacheprovider --basetemp=/tmp/bt tests/test_issue25_plan_reuse.py) # 13 failed
# 실패 2건이 환경 문제임을 확인
(cd "$RP" && AGENT_PYTHON_BIN="$PY" PYTHONDONTWRITEBYTECODE=1 "$PY" -m pytest -q -p no:cacheprovider --basetemp=/tmp/bt2 \
tests/test_loop_cli.py::test_creator_parses_and_reaches_session_check \
tests/test_issue5_phase3_loop.py::test_p3_02_run_loop_wrapper_delegates_to_python_default) # 2 passed
```
[VERDICT: PASS]
@@ -0,0 +1,106 @@
# 🔍 Review Report — PR #41 델타 리뷰 2회차 (job `2cc309ea`)
- **Reviewer**: `planner-reviewer-claude-01` (role: planner,reviewer) · 2026-09-26
- **대상 델타**: `1673818..af0a2a2` (1커밋, 5파일 `+406/−12`, `git diff --check` 클린), base `main 8ea65bb` (v4.7.4).
- **검증 경로**: `/var/folders/.../T/mam-review/loop-20260926T052018Z-597343/1`. **identity 확인**: `HEAD = af0a2a25…3a1a`, `HEAD^{tree} = 03106826…6ce8` — 브리프와 일치.
- **1회차 내 판정**: NOT PASS(N1) + M1~M9 (job `ed0e1492`). 이번에는 N1·codex R1/R2 정정과 내 비차단 항목의 처리를 본다.
- **주의**: 같은 델타(동일 base·head·tree)에 대한 직전 잡 `ce114c80` 은 내가 검증 중에 오케스트레이터 쪽에서 `cancelled` 되었고 그 검증 경로는 사라졌다. 코드 검토·하네스 실험은 그때 것을(트리가 동일하므로) 그대로 쓰고, 테스트 실행은 **이 잡의 검증 경로에서 다시** 돌렸다(§6).
- **안전 규칙 준수**: 검증 경로·소스 워크트리·본 체크아웃(`main 8ea65bb`) 모두 수정·커밋·checkout 없음. 실험은 scratchpad 하네스(`stop_stall_watch` 함수 추출, 대역 프로세스는 내가 만들고 내가 정리)·in-process 모듈 검사·되돌린 사본에서만. 실제 세션에 stall_watch 를 붙이지 않았고 실제 `.mam`·데몬·herdr 서버 조작 없음, push·merge·PR 상태 변경 없음. 1회차에서 남았던 멈춘 래퍼·감시자는 이번 시작 시점에 더 이상 없었다.
## 결론 요약
**N1 은 닫혔다.** 래퍼의 `stop_stall_watch` 는 TERM 을 무시하는 감시자에 대해서도 **6 s 안에 돌아오고**(실측), 감시자는 `--watch-pid` 로 래퍼가 사라지면 스스로 끝난다. R2 의 재검사(`_dispatch_open`)는 wait 직후·nudge 직전·루프 머리 세 곳에 있고, 예외 정책도 타당하다. 비차단 관찰 2건(P1·P2).
## 1. N1 — 유계 stop (검토 요청 1~2)
`multi-agent-mux-delegate-job:710-727` `stop_stall_watch`: `kill -0` → `kill`(TERM) → 0.2 s × 25 폴링 → 남아 있으면 stderr `ignored TERM; killed` + `kill -9` → `wait`. 함수를 그대로 추출해 네 가지 대역에 돌렸다:
| 대역 | 결과 |
|---|---|
| TERM 을 무시(`trap "" TERM; exec sleep 300`) | **6 s** 에 rc 0 반환, stderr `stall watch for j1 ignored TERM; killed (pid …)`, 대역 사망 |
| 정상(TERM 에 죽음) | 0 s |
| `STALL_PID` 비어 있음 | 즉시 return 0 |
| 이미 종료(rc 2) | 즉시 반환, stderr `stall watch for j1 exited rc=2:` + 로그 꼬리 (기존 보고 유지) |
`wait` 가 다시 무한이 될 경로: KILL 뒤의 `wait` 는 SIGKILL 된 자식에 대해 즉시 돌아오고(실측), `kill -0` 실패 분기의 `wait` 는 이미 끝난 자식의 저장된 상태를 돌려준다. 남는 이론적 경우는 자식이 D 상태(커널 내 대기)로 KILL 을 못 받는 것뿐인데 감시자는 파이프·소켓 대기라 해당하지 않는다. **1회차 N1 의 원인 가설(fork-exec 창에서 TERM 이 삼켜짐)** 은 PR 이 래퍼 trace 로 확인했다고 밝혔고, 내 1회차 관찰(래퍼 `__wait4`, 감시자 정상 루프, 수동 TERM 에 즉시 종료)과 정합한다 — 어느 쪽이든 유계 stop 이면 닫힌다.
`--watch-pid "$$"`(`:695`): `start_stall_watch` 는 메인 셸의 함수라 `$$` 는 래퍼 pid 다. 감시자 `run()` 루프 머리에서 `_alive(watch_pid)` 가 거짓이면 rc 0(`stall_watch.py:298-303`). `_alive` 실측: 살아 있는 자식 True, **pid 1(권한 없음) True**, reaped/없는 pid False — 권한 오류를 "살아 있음" 으로 보는 규칙이 맞고, `watch_pid` 가 0/None 이면 `if self.args.watch_pid and …` 가 검사를 건너뛴다(`os.kill(0,0)` 은 프로세스 그룹 시그널이라 항상 True 인데, 그 값이 들어올 경로는 없다).
## 2. R2 — 재검사 위치와 예외 정책 (검토 요청 4)
`_dispatch_open`(`stall_watch.py:266-285`): 레코드 재읽기 → 종단 상태·`dispatch_token` 불일치·`attempt_id` 불일치면 False. 호출 지점 세 곳: 루프 머리(`:296`), **herdr wait 직후**(`:332-334`, 블로킹 대기 중 닫힌/재디스패치된 잡), **nudge 직전**(`:384-386`, `collect()` 가 시간을 쓴 뒤). 충분하다 — prompt 를 보내는 유일한 자리 앞에 두 겹이 있다.
- 예외: `FileNotFoundError → False`(레코드 삭제 = 잡 없음, 감시 중단이 안전), 그 외 `OSError/ValueError → True`(원자적 갱신 중의 일시 상태를 종결로 오인하지 않음) — 타당하다.
- blocked 경로는 재검사하지 않는다: 보내는 것이 `progress` 알림뿐이고 prompt 가 없다. 닫힌 잡에 대한 `progress` 는 `publish_event` 의 CAS(토큰)·종단 뒤 거부가 막고, 설령 기록돼도 사람에게 가는 알림 한 줄이라 무해 — 그대로 둬도 된다.
- `_notify` 가 False(rc ∉ {0,2})면 nudge 를 보내지 않고 `nudged=True` 로 두어 재시도하지 않는다(`:387-391`) — "잡당 1회" 계약 유지.
## 3. R1 — `--watch-pid` 만으로 닫은 판단 (검토 요청 5)
동의. 래퍼가 TERM/KILL 로 사라지면 그 셸의 어떤 trap 도 돌지 않으므로 trap 합성은 R1 을 닫지 못한다; 자식이 부모 생존을 보는 것이 유일하게 확실한 방법이고 #18 heartbeat 타이머의 `--watch-pid` 와 같은 계약이다. 지연은 최대 herdr wait 한 번(≤ 60 s)이며 문서(`SKILL.md:51-52`)가 그렇게 말한다. 감시자가 대기 중 만든 herdr 자식은 `--timeout` 으로 스스로 끝나고, 새 테스트 (d) 가 "TERM 받은 래퍼 뒤에 감시자가 30 s 안에 rc 0" 을 고정한다. 기존 bootstrap EXIT trap 과의 합성은 항목 4 로 미루는 것이 맞다(래퍼 흐름 변경).
## 4. M8 좁힘과 r4 (검토 요청 6)
`test_issue30_stall_watch.py:39-40` 이 모듈 마크를 `PROC` 데코레이터로 바꾸고 S6(`/proc` 직접 읽기)에만 붙였다. `box` 기반 시험은 실제 프로세스를 띄우고 `background_children` 의 macOS `ps` 경로를 지나므로 이 호스트에서 **실제 검증**이 된다(1회차에 내가 손으로 확인한 경로). r4 의 stub 시계 `monotonic → time`: stub 프로세스와 판정 프로세스가 서로 다른 프로세스라 macOS 의 프로세스별 monotonic 기준점 차이를 피하는 것이 맞다(codex 지적 타당).
## 5. 1회차 내 항목의 처리
| # | 1회차 항목 | 이번 델타 |
|---|---|---|
| N1 | 래퍼가 감시자를 기다리며 영구 정지 | **닫힘** — 유계 stop + `--watch-pid` + 테스트 (a)(b)(d) |
| M1 | 유예 루프의 시계 판정이 bash | 열림 → #17 항목 4 목록(PR 명시) |
| M2 | 감시 여부 판정이 bash | 열림 → 항목 4 |
| M3 | 감시자 수명이 래퍼 정상 경로에만 묶임 | **닫힘** — `--watch-pid` |
| M4 | `MAM_JOB_TIMEOUT_S` 오류 시 러너 무언 | 열림 → 항목 4 |
| M5 | "before v4.7.5" | **닫힘** — v4.8.0 |
| M6 | `MAM_WAIT_GRACE_S` 미문서 | 열림 → 항목 4 목록(PR 명시) |
| M7 | `MAM_TEST_UNRELIABLE_AGENT` 훅 | 열림(후속, 사유 타당) |
| M8 | 모듈 전체 Linux skip | **닫힘** — S6 만 |
| M9 | B3 의 공개 브로커 의존 | 열림(후속; "started 자체가 안 닿는 경우" 라는 진단에 동의 — 로컬 events.log 로 heartbeat 를 넣는 방식이 남은 선택지) |
## 6. 테스트 (검토 요청 3 + 검증 방법)
### 6-1. 이 잡의 검증 경로에서의 실행 (브리프의 명령 그대로, pr-41 `.venv` 인터프리터)
```
tests/test_issue30_stall_watch_stop.py tests/test_issue30_stall_watch.py tests/test_issue14_delivery_ack.py tests/test_tier1_unit.py
→ 1 failed, 132 passed, 1 skipped in 589.33s (0:09:49)
FAILED test_issue30_stall_watch.py::test_s9_the_wrapper_runs_the_watcher_for_the_jobs_life[0]
SKIPPED test_issue30_stall_watch.py:331 (S6, real process trees via /proc)
```
- **새 stop 테스트 7건 전부 통과**, `test_issue30` 는 M8 좁힘대로 S6 하나만 skip 이고 나머지(box 기반 S1–S5·S7·S8·S13, R1–R4 포함)가 이 호스트에서 **실제로 돈다**. 1회차에서 멈췄던 `test_issue14::test_wrapper_honours_registry_ack_over_unconfirmed_inject` 는 합동·단독 모두 통과 — **N1 재현 없음**.
- **S9[0] 실패는 공개 브로커 flake** 다: 단언은 `t18._publish(... "started").returncode == 0` 이고 stderr 는 `publish failed after 2 attempts: no CONNACK from broker`. S9 는 `_live_broker()` 로 **일부러** 공개 브로커를 쓰며(래퍼 구독자가 와이어를 봐야 하므로), 진짜 도달 불가 브로커(127.0.0.1:9)에 대해 `publish_event` 는 6/6 회 rc 2 를 돌려준다 — 즉 0 을 기대하는 이 단언은 브로커가 응답할 때만 성립한다. 같은 세 시험(S9[0]·R2[True]·issue14 pane_busy)을 단독으로 다시 돌리자 그 셋은 통과하고 이번엔 **R2[False]** 가 같은 `no CONNACK` 로 실패했다 — 어느 케이스가 걸리는지가 브로커 응답 여부로 바뀌는 전형적 T-1 이다. `publish_event` 는 이 델타가 건드리지 않았다(P2).
- 같은 트리(1673818..af0a2a2 는 동일 tree `0310682`)에 대한 직전 잡 `ce114c80` 의 실행(취소 전 완료)도 130 passed / 3 failed(S9[0]·R2[True]·issue14 pane_busy — 전부 위 부류) / 1 skipped 로 같은 그림이었다.
### 6-2. 수정 없이는 실패하는가 — 확인함
사본에서 래퍼와 `stall_watch.py` 만 `1673818` 판으로 되돌리고 `test_issue30_stall_watch_stop.py` 를 돌린 결과 **7 failed / 0 passed (134 s)** — (a)(b)(c)(d) 는 `TimeoutExpired`, (e)(f)(g) 는 AssertionError. 되돌린 실행이 남긴 프로세스는 없었다(대역·shim 은 테스트가 정리).
### 6-3. 결정성·잔류 (검토 요청 3)
- (a) 대역은 `kill -9` 로, (b) shim 은 래퍼가, (c)(d) 의 `sleep 300`/감시자는 테스트의 `finally` 가 죽인다 — 두 번의 전체 실행과 되돌린 실행 뒤 **`stall_watch --job`/`sleep 300` 잔류 0**(호스트에 남은 `lib_py` 프로세스는 오케스트레이터의 루프 러너뿐).
- 시간 상한은 (a) 4–12 s, (b) 40 s, (c)(d) 30 s 로 stop 의 5 s 폴링과 감시자의 최대 60 s wait 를 넉넉히 감싼다. 이번 실행에서 stop 테스트들은 상위 8 느린 시험에 들지 않았다.
- 실제 `.mam/jobs` 전후: 이 잡 자신의 heartbeat/레코드 파일 외 **추가 0**. 검증 경로 tracked 파일 변화 없음(HEAD `af0a2a2`), 본 체크아웃 `main 8ea65bb`.
## 7. 판정
N1(래퍼 영구 정지)은 유계 stop(실측 6 s)과 `--watch-pid`(부모 소멸 시 자진 종료)로 **양쪽에서** 닫혔고, R2 는 prompt 앞 두 겹 재검사로 닫혔으며, R1 은 `--watch-pid` 만으로 닫은 판단이 맞다. 1회차 판정을 뒤집을 요소는 없고, 델타 밖의 남은 항목(M1/M2/M4/M6 → 항목 4, M7·M9 후속)은 PR 이 명시했다. 버전 락스텝 bump 는 브리프대로 판정에서 제외한다.
| # | 비차단 관찰 | 근거 |
|---|---|---|
| P1 | 감시자가 부모 소멸을 알아채는 지연이 최대 herdr wait 한 번(≤ 60 s)이라 문서와 일치하지만, idle 상태에서는 wait 가 30 s 단위라 실제 지연은 그보다 짧고 `blocked`/`working` 대기(60 s)에서 가장 길다 | `stall_watch.py:276-283`; 정보 |
| P2 | S9/R2 가 공개 브로커에 의존해 `no CONNACK` 로 간헐 실패한다(이 호스트 3회 실행 중 매번 1–3건). `_live_broker()` 가 설계라면 rc 0 대신 로컬 `events.log` 의 `started` 줄로 단언하거나 rc 2 를 허용하는 편이 결정적 | `test_issue30_stall_watch.py` S9 `_publish(...).returncode == 0`; T-1 부류 |
## 8. 교차검증 가이드
```bash
RP=/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20260926T052018Z-597343/1
PY=/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux-wt/pr-41/.venv/bin/python
git -C "$RP" rev-parse HEAD 'HEAD^{tree}' # af0a2a2… / 0310682…
# stop_stall_watch 만 추출해 TERM 무시 대역에 → 6 s 안에 "ignored TERM; killed" 후 반환 (§1 표)
# _alive: 살아있는 자식 True / pid 1 True(권한) / reaped False (in-process)
# 되돌린 사본: 래퍼·stall_watch.py 를 1673818 판으로 → tests/test_issue30_stall_watch_stop.py → 7 failed
(cd "$RP" && AGENT_PYTHON_BIN="$PY" PYTHONDONTWRITEBYTECODE=1 "$PY" -m pytest -p no:cacheprovider -q --basetemp=/tmp/bt \
tests/test_issue30_stall_watch_stop.py tests/test_issue30_stall_watch.py tests/test_issue14_delivery_ack.py tests/test_tier1_unit.py)
```
[VERDICT: PASS]
@@ -0,0 +1,100 @@
# 리뷰 리포트 — Job 2f19fe7f (PR #46, fix/issue-42 단계 1·2)
- 리뷰어: planner-reviewer-claude-01 (claude)
- 리뷰 identity: loop-20261003T222707Z-a0de27 / base 45d1621 / head 8d26106 / tree b579380 — 검증 경로에서 `git rev-parse HEAD 'HEAD^{tree}'` 로 일치 확인, 미커밋 변경 없음
- 커밋: 7670b41(단계 1·2), e6a46cd(R1: stop exit 1 이 stop 뒤에도 남), 8d26106(R2: passthrough 코드는 표에 있어야 함)
## 결론 요약
차단 결함 없음. 지운 (a) 블록 5개는 모두 진입 스크립트가 그대로 하는 일이고, 새 옵션·종료 코드 표와 "정정" 다섯 곳은 스크립트 코드 및 샌드박스 실행 결과와 일치한다. 제품 코드 변경 없음. 비차단 관찰 4건(아래 §6).
## 1. 분류의 정확성
### resume Workflow (base resume SKILL.md:59-118) → resume_session.sh
| 지운 단계 | 스크립트 위치 |
|---|---|
| 세션 ID 해석 (`resolve_session_id.sh --session`) | resume_session.sh:111-112 |
| Class A fresh-spawn 폴백 / claude·grok exit 1 | :122-130 (spawn-spec :173-184) |
| HERDR_SESSION_NAME·MAM_WS_LABEL 해석 | :132-144 |
| herdr alive 분기 | :147-158 — **attach 하지 않고** `update_yaml_resumed.sh` 후 exit 0 |
| CMD_FULL (에이전트별) | :185-197 (adapter resume-spec + 동일 표 폴백) |
| 기동 | :237 `_herdr new-session` |
| handle_startup_dialogs | :241 |
| update_yaml_resumed.sh | :247-250 |
| attach | **스크립트에 없음** |
attach 만 스크립트에 없는 단계(= (b))인데, 문서는 이를 지우지 않고 "It never attaches; attaching is a separate step: `herdr agent attach <name>`" 로 남겼다(resume SKILL.md Workflow 아래 문단, Case 3 정정). 따라서 절차 유실 없음. 문서에만 있던 `$CLAUDE_ID_FLAG`(정의되지 않은 변수)도 함께 사라졌다(인벤토리 mismatch R4) — 정정이 맞다.
### stop pre-flight (base stop:38-63) → stop_session.sh
YAML 존재 :99(exit 1), 등록 확인 :127-143(exit 1), 이미 stopped no-op :184-188(exit 0). 문서에만 있던 `--force-no-yaml` 은 어떤 스크립트도 받지 않는다(:76-90 파서에 없음) — 삭제가 맞다.
### create pre-flight (base create:35-52) → create_session.sh
워크스페이스 :111, has_real_herdr :114, CLI in PATH :115, 인증 :117-157(선택된 에이전트만). B-3 함정(command -v / type -P) 설명은 산문으로 보존.
### create Workflow (base create:168-220) → create_session.sh
이미 존재 :166-168(**exit 3**, 문서는 1이었음), spawn-spec :207, spawn :252-276, TUI 대기 :361-398(exit 42), 롤백 trap :320-352(exit 43). `sleep 6` 하드코딩과 claude 무플래그 기동은 문서 쪽 오류였다.
### create YAML 수동 추가 (base create:226-252)
행 기록은 create_session.sh:491-610 heredoc 이 한다. lib.sh shim 주석은 Output format 아래 산문으로 이동.
### (b) 로 남은 블록 5개
create:217·resume:103·stop:127 (사후 확인), monitor:182 (에이전트 폴링 지침), status:30 (status.sh 에 has_real_herdr 검사가 없음 — status.sh:22 는 YAML 만). 각 스크립트를 grep 해 같은 명령을 제공하는 진입점이 없음을 확인했다. 3단계 입력으로 타당하다.
### 블록 수 대조
base 의 펜스 블록을 테스트의 `parse_blocks` 로 세면 37개(create 11, delegate-job 1, loop 5, monitor 7, orc-onboard 2, resume 2, status 4, stop 5). 지운 (a) 5개의 base_start/base_sha 는 모두 base 와 일치한다. 현재 33개 = 37 − 5(a) − 1 + 2(새 Workflow 호출 블록). 이 "− 1"은 관찰 O1 참고.
## 2. 옵션·종료 코드 표 대조
- create 옵션 표 ↔ 파서 create_session.sh:76-92: 12개 항목 모두 일치(별칭 `--name`, `--herdr-server` 포함; `--isolate` no-op :89-90; `--wrapper` 는 :257 에서 존재 여부와 무관하게 강제).
- create 종료: 1 = :111-156 사전 검사 + :636·:659 (spawn 뒤 브리프/주입 실패, trap 롤백); 2 = :92-110 인자, :234 바인딩 거부, :245 공백; 3 = :168; 4 = heredoc `raise SystemExit(4)` :515 (중복 running 행, set -e 로 통과 → trap 롤백); 42 = :398; 43 = :348.
- resume 옵션 표 ↔ :37-46 일치. 종료 0 = :150·:158(alive, attach 없음)·:227(dry-run)·정상 종료; 1 = :128·:204·:209 + update_yaml_resumed.sh:47; 2 = :46-56·:73·:77·:104·:221 + update_yaml_resumed.sh:42-46·:74.
- stop 종료 0 = :188; 1 = :99·:142 + heredoc :310(R7); 2 = :86·:89·:95·:98·:121; 3 = :198; 4 = :281.
- status `--json` 키: reconcile.sh:1081-1088 의 6개 최상위 키 + status.sh:213-232 의 sessions_detail 17개 키와 정확히 일치. resume_state 값 yes/MISSING/scan/no 도 status.sh 의 resume_on_disk 반환값과 일치.
샌드박스 실행(임시 HOME·WORKSPACE_ROOT·AGENT_SESSIONS_YAML, `env -i`, herdr 미호출 경로만):
| 실행 | rc | 표 |
|---|---|---|
| stop `--purge-conversation` (no --yes) | 3 | 3 ✓ |
| stop 미등록 세션 | 1 | 1 ✓ |
| stop `--mode x` | 2 | 2 ✓ |
| resume 원래 cwd 없음 `--dry-run` | 2 | 2 ✓ |
| create 없는 워크스페이스 | 1 | 1 ✓ |
| create `--agent foo` | 2 | 2 ✓ |
샌드박스 YAML 은 변경되지 않았다.
## 3. 문서만으로 같은 작업이 가능한가
- 세 진입점 모두 호출 한 줄에 필수 옵션이 표시되어 있고, 지운 절차는 전부 스크립트 안에 있으므로 호출 한 줄로 충분하다.
- 사람이 판단할 지점은 보존·강화됐다: stop 예제가 `--purge-conversation --yes` 로 바뀌면서 "되돌릴 수 없다, 사용자가 확인한 뒤에만 `--yes`" 문장이 추가됐다(실제로 --yes 없으면 exit 3 이므로 이전 예제는 동작하지 않았다).
- resume 은 spawn 뒤 실패 시 롤백하지 않는다는 점과 `multi-agent-mux-status` 로 먼저 확인하라는 복구 지침이 새로 들어갔다(resume_session.sh 에 trap 없음 — 사실과 일치). stop 표 아래 복구 문단도 같다.
## 4. 문서 가드 테스트 견고성
스크래치 사본에서 변이 시험:
| 변이 | 결과 |
|---|---|
| create `exit 43`→`exit 44` | 2 failed ✓ |
| create `exit 42` 제거(표 행이 낡음) | 2 failed ✓ |
| 주석 `# exit 9` 추가 | 통과 ✓ (주석 제외 규칙) |
| 실제 `exit 9` 추가 | 2 failed ✓ |
| stop 표의 3 행 삭제 | 2 failed ✓ |
| stop 예제의 `--yes` 제거 | 1 failed ✓ |
| base 의 create pre-flight 블록 되살림 | 1 failed ✓ |
| stop SKILL.md 블록 위에 산문 한 줄 추가(정상 편집) | 1 failed (관찰 O2) |
| `$(( 1 << 2 ))` 뒤 `exit 9` | 통과 ✗ (관찰 O3) |
추출 규칙: `(?<![\w.])exit\s+(\d+)` 은 `sys.exit(7)`(stop:139) 을 세지 않고, heredoc 본문은 `SystemExit(N)` 만 passthrough 로 모은다. `<<<` here-string 은 lookbehind 로 제외된다. 기존 가드 test_tier2_component.py::test_comp_docs_stop_examples_pass_agent(stop 예제 ≥3, 모두 --agent)는 아래 테스트 실행에서 통과한다.
## 5. 범위
`git diff --stat 45d1621 8d26106`: SKILL.md 4개 + tests/data/skill_code_blocks.json + tests/test_issue42_skill_blocks.py. 스크립트·Python·버전 파일 변경 없음(버전 bump 없음).
## 6. 비차단 관찰
- **O1 인벤토리 누락 1건**: base create SKILL.md:263-266 의 (c) 호출 블록(`create_session.sh … --session "$SESSION_NAME"`, sha ceb96a8c1293)이 지워졌지만 skill_code_blocks.json 의 blocks·removed 어디에도 없다. 내용은 새 Workflow 호출 블록이 대체하므로 정보 유실은 없지만, "37개를 분류했다"는 데이터는 실제로 36개만 다룬다. 가드는 현재 블록만 비교하므로 잡지 못한다. removed 에 class "c" 로 기록하거나 test_no_duplicate_block_left 의 `class == "a"` 를 완화하는 정리를 권한다.
- **O2 줄 번호 결합**: 인벤토리가 start/end 줄 번호를 고정하므로 블록 위에 산문 한 줄만 넣어도 실패한다. 실패 메시지가 실제 튜플을 출력해 갱신은 기계적이지만, 3단계에서 산문 편집이 잦다면 sha+순서만 비교하거나 재생성 스크립트를 두는 편이 낫다.
- **O3 산술 시프트 오인**: `_HEREDOC` 이 `$(( 1 << 2 ))` 를 heredoc 시작으로 읽어 이후 모든 줄을 건너뛴다. 현재 세 진입 스크립트에는 이 패턴이 없어(grep 확인) 실제 영향은 없다.
- **O4 표 세부 문구**: (a) create exit 42 는 "프로세스가 사라짐" 외에 `MAM_KILL_ON_READY_TIMEOUT=1` 이면 살아 있어도 42 (create_session.sh:388-398). (b) stop "이미 stopped 면 no-op" 은 `--purge-conversation` 이 아닐 때만(:185). (c) resume 문서의 "lines 50-223" 은 사전 검사의 실제 범위(:50-221)와 대략 맞다. 모두 문서 정밀도 수준이다.
## 7. 항목 6 — purge 기준 실패
이 호스트에서 검증 경로의 tests/test_issue10_13_purge.py: **23 passed**. 이 PR 은 제품 코드를 바꾸지 않으므로 Linux 의 12건(#28)과 무관하다. "알려진 기준 실패" 표현은 Linux 기준이라는 한정이 필요하다.
## 8. 테스트
지정 6개 파일(test_issue42_skill_blocks, test_tier2_component, test_orc_onboard, test_loop_cli, test_o1_rebuttal, test_version_consistency): **129 passed / 0 failed / 1 skipped** (296 s, rc 0). skip 은 test_claude_skills_link_resolves_to_agents_skills(워크트리에 .claude/skills 없음 — 브리프상 정상). test_issue42_skill_blocks.py 단독: 28 passed / 1 skipped.
외부 브로커 미사용(MQTT_* unset, .mam.env 미export). 실제 .mam/jobs 항목 수 변화: +0 (이 잡 디렉터리 외 생성 없음).
[VERDICT: PASS]
@@ -0,0 +1,14 @@
{
"loop_id": "loop-20261001T230324Z-b511bf",
"base_sha": "0051fd6c3b088f8b265928ccd94ea31053fdde7e",
"head_sha": "75f364e4b47d8db95ce2a7e041b69b8e8b473e64",
"tree_sha": "ec1fdc453ec6fdf2c09165c92a64d54e684f5a3a",
"review_commit": "75f364e4b47d8db95ce2a7e041b69b8e8b473e64",
"reviewer": "planner-reviewer-claude-01",
"job_id": "31a04751",
"verdict": "PASS",
"transport": "herdr",
"evidence": "review-tree",
"isolation": "worktree",
"verified_at": "2026-10-02T00:58:55Z"
}
@@ -0,0 +1,38 @@
# 🔍 Review Report — 후속 `fix/followups-41-43` 2회차 (job `31a04751`)
- **Reviewer**: `planner-reviewer-claude-01` (role: planner,reviewer) · 2026-10-02
- **대상**: `0051fd6..75f364e`(9커밋). 1회차(`95eabc6`, 내 판정 PASS — job `76ca3b1b`) 이후 델타는 `75f364e` 1커밋(3파일 `+41/−2`, `git diff --check` 클린).
- **검증 경로**: `/var/folders/.../T/mam-review/loop-20261001T230324Z-b511bf/2`. **identity 확인**: `HEAD = 75f364e4…3e64`, `HEAD^{tree} = ec1fdc45…5a3a` — 브리프와 일치.
- **안전 규칙 준수**: 검증 경로·소스 워크트리·본 체크아웃(`main 0051fd6`) 수정·커밋·checkout 없음. 되돌린 사본은 scratchpad 에서만. 실제 `.mam.env` 를 읽거나 출력·export 하지 않음. 실제 `.mam`·데몬·세션·herdr 조작 없음, push·merge·PR 상태 변경 없음.
## 결론 요약
**델타는 codex R1(실패한 env 로드가 "로드됨" 으로 캐시됨)을 정확히 닫고, 1회차 판정(F1–F6 완료, PASS)을 뒤집을 요소는 없다.** 1회차 내 리포트의 F1–F6 판단은 이 트리에도 그대로 유효하다 — 델타가 F2 의 `mqtt_common.py` 두 줄, MESSAGING.md 한 줄, 테스트 1건뿐이기 때문이다.
## 1. 75f364e — 실패한 로드를 캐시하지 않음 (`mqtt_common.py:128`)
- **결함(codex R1)**: F2 에서 `load_env_or_report()` 가 프로세스를 끝내지 않고 False 를 돌려주게 되자, `ensure_env_loaded()` 가 `_load_dotenv()` **이전에** `_env_loaded = True` 를 세우던 순서가 문제가 됐다. 한 프로세스 안에서 두 번째 `main()` 은 "이미 로드됨" 으로 보고 **설정 없이** 진행했다.
- **수정**: 플래그를 `_load_dotenv()` **뒤로** 옮겨, 성공한 로드만 캐시한다. `EnvFileError` 가 나면 플래그는 거짓으로 남아 다음 호출이 파일을 다시 읽는다.
- **수정 없이는 실패하는가 — 확인함**: 사본에서 `mqtt_common.py` 만 `95eabc6` 판으로 되돌리고 새 `test_a1_a_failed_load_is_not_cached_as_loaded` 를 돌리면 실패한다. 실패 내용이 결함 그대로다: `rc: [1, 0]`(두 번째 `registry.main` 이 rc 0), `registered: ['<job>.json']`(**설정 없이 잡을 등록**), `recovered: [True, None]`(고친 파일도 다시 읽지 않음). 새 코드에서는 `rc [1, 1]`·등록 0건·두 번 모두 한 줄 `ERROR`·고친 파일 읽힘·성공 뒤 캐시 유지(바꾼 파일을 다시 읽지 않음)를 모두 단언한다.
- **부분 적용 여부**: `envfile.parse` 는 파일 전체를 먼저 읽고 디코딩한 뒤에만 값을 넣으므로, 실패한 로드가 환경을 절반만 바꾸는 일은 없다. `.mam.env` 와 옛 `.env` 가 함께 있으면 `_load_dotenv` 는 `.mam.env` 하나만 읽는다. 따라서 재시도 때 두 파일이 섞이는 경로도 없다.
- **MESSAGING.md:369**: 문장 끝을 실제 동작(파일 없음 → 조용히 건너뜀, 있는데 못 읽음 → 진입점이 `ERROR` 한 줄 + rc 1)에 맞춘 문구 정리.
## 2. 테스트
```
tests/test_issue26_envfile.py tests/test_issue26_ints.py tests/test_issue21_acceptance.py tests/test_issue30_stall_watch.py
tests/test_issue30_stall_watch_stop.py tests/test_tier1_unit.py tests/test_version_consistency.py
tests/test_issue5_phase1_delegation.py tests/test_issue19_subscriber_daemon_lifecycle.py
(.mam.env 미export, MQTT_* 미설정, followups .venv)
→ 352 passed, 1 skipped in 488.95s failed 0 (skip = S6, /proc)
```
- 브리프 목록 7파일에 두 파일을 더했다. `test_issue5_phase1_delegation.py` 는 CLI 스냅샷이고, `test_issue19_subscriber_daemon_lifecycle.py` 는 F4b 로 바뀐 `reconcile.sh` 의 `RECONCILE_POLL_INTERVAL` 을 거치는 구독자 데몬 수명 시험이다.
- 로그에 외부 브로커 흔적 0건.
- 실제 `.mam/jobs` 는 이 잡 자신의 파일 외 **추가 0**. 검증 경로 tracked 파일 변화 없음(HEAD `75f364e`), 본 체크아웃 `main 0051fd6`.
## 3. 판정
1회차 비차단 관찰(T1 `test_o2` 의 미사용 `get_lstart`, T2 reconcile 내장 구독자의 트레이스백, T3 설치 확인 목록)은 범위 밖으로 그대로 열려 있다. 버전 bump 는 판정에서 제외한다. 병합을 막을 결함은 없다.
[VERDICT: PASS]
@@ -0,0 +1,30 @@
# 리뷰 리포트 — Job 3a93c30b (PR #48 2회차, 35935f9..e8d9151)
- 리뷰어: planner-reviewer-claude-01 (claude)
- 리뷰 identity: loop-20261005T081844Z-e92124 / base c5c7eff / head e8d9151 / tree 8b39188 — 검증 경로에서 `git rev-parse HEAD 'HEAD^{tree}'` 일치, 미커밋 변경 없음
- 범위: 커밋 36f0b8d, e8d9151. `git diff --stat 35935f9 e8d9151`: create SKILL.md 1줄, VERSIONS.md, 테스트 3개. lib.sh·lib_py·스크립트 변경 없음(제품 코드 불변).
## 결론 요약
1회차의 차단 3건(B1~B3)과 문서 지적(D1)이 모두 닫혔다. 이 macOS 호스트(Python 3.9 venv)에서 지정 6개 파일이 실패 없이 통과하고, 실제 herdr 서버 테스트 5개가 skip 없이 돈다. 차단 결함 없음.
## 1. 1회차 지적 종결
| 지적 (1회차) | 수정 | 확인 |
|---|---|---|
| B1 test_issue45_herdr_config.py:16 최상단 `import tomllib` → 3.9 에서 수집 중단 | try/except 로 선택적 import(:18-21), 결과를 파싱하거나 설정 유지를 기대하는 테스트만 `needs_tomllib` skip(:36) | 3.9: 수집 정상, tomllib 의존 검사만 skip. Python 3.11(Homebrew + PYTHONPATH): **51 passed**. 3.9 에서 남는 22건 중 test_without_tomllib_restore_is_still_off(:91-96)가 tomllib 없을 때 MINIMAL + "no tomllib" 경고를 직접 확인하므로 3.9 경로도 검사된다 |
| B2 test_issue45_restore.py `/proc` 와 `/tmp` | 루트 `.resolve()`(:175), 서버 환경은 `/proc` 가 있으면 거기서, 없으면 `ps -E -ww`(:160-169, :239-240) | test_issue45_restore.py 5개 모두 **실행·통과**(skip 아님). 1회차에 내가 스크래치 사본으로 확인한 것과 같은 두 수정이며, 그때 A·B 수정을 끄면 각각 실패함(A: `--resume <id>`, B: pane 2개)을 확인했다. 제품 코드가 그 뒤 바뀌지 않았으므로 대조 결과는 그대로 유효하다 |
| B3 test_issue9_server_env.py:157 tomllib, bootstrap 의 python3 가정 | bootstrap 과 같은 PATH 의 `python3` 로 결과를 파싱, tomllib 이 없으면 키 하나짜리 파일 + `confirm_close` 없음 + 경고 "no tomllib" 를 기대(:169-183) | 통과. 이 호스트에서는 else 분기(3.9)가 실행된다. `subprocess.run(["python3"…], env=env)` 는 POSIX 에서 env 의 PATH 로 실행 파일을 찾으므로 bootstrap 과 같은 인터프리터다. 경고 문구는 herdr_config.py:147 와 일치 |
| D1 (비차단) python3 < 3.11 이면 사용자 설정 없이 뜬다는 문서 부재 | create SKILL.md:120 와 VERSIONS 4.8.5 항목 1, 품질 검증 항목에 추가 | 내용은 코드(lib.sh:241 의 bare `python3`, herdr_config.py:146-147)와 일치 |
## 2. 테스트 실행
| 실행 | 결과 |
|---|---|
| 지정 6개 파일 (pr-48 .venv, Python 3.9.6) | **152 passed / 30 skipped / 0 failed** (216 s). skip = tomllib 필요 29건 + .claude/skills 1건(워크트리 정상). test_issue45_restore 는 skip 0 |
| tests/test_issue45_herdr_config.py (python3.11) | **51 passed** |
- 격리: 실행 전후 herdr 프로세스는 기존 `multi-agent-mux` 서버·attach 클라이언트와 오케스트레이터의 `agent wait` 뿐(잔류 서버 없음), `/private/tmp/m45*` 없음, `~/.config/herdr` 목록·config.toml md5·sessions 목록 불변. MQTT_* unset, .mam.env 미export. 실제 .mam/jobs 에 새 항목 없음.
## 3. 비차단 관찰
- **N1 VERSIONS.md 4.8.5 항목 1 문장 흐름**: 새 문장이 "…경고가 남는다:" 로 끝나고 바로 뒤에 원래 있던 "출력이 원본 자신(별칭 포함)이거나 … 원본별 이름을 쓴다" 가 이어져, 출력 이름 규칙이 Python 버전 설명의 결과처럼 읽힌다. 원래는 "사용자 파일은 고치지 않는다:" 뒤에 붙던 설명이다. 새 문장을 항목 끝으로 옮기면 된다.
- **N2 `_process_env` 의 macOS 분기**(test_issue45_restore.py:166-169)는 `ps -E` 출력을 공백으로 나누므로 공백이 든 경로는 맞추지 못한다. 테스트 루트가 `/private/tmp/m45…` 로 고정이라 지금은 영향 없다.
- **N3 tomli 백포트 대신 문서화한 결정**에 대한 의견: 복원은 어느 경우에도 꺼지므로(#45 의 핵심) 안전 측면에서 문제없고, 이 호스트의 herdr 설정이 키 하나뿐이라는 사정에서 합리적이다. 나중에 사용자 설정이 커지면, 새 의존성 없이 PATH 에서 3.11+ 인터프리터를 찾는 방법도 고려할 만하다.
[VERDICT: PASS]
@@ -0,0 +1,45 @@
# 리뷰 리포트 — Job 40f4077c (PR #47, fix/issue-42-skills-link)
- 리뷰어: planner-reviewer-claude-01 (claude)
- 리뷰 identity: loop-20261004T043243Z-baf133 / base 6af4a79 / head e1ed270 / tree 2f781e0 — 검증 경로에서 `git rev-parse HEAD 'HEAD^{tree}'` 일치, 미커밋 변경 없음
## 결론 요약
수정은 두 배치(단일 링크 / 스킬별 링크 디렉터리)를 모두 올바르게 받고, 잘못된 배치는 실패시킨다. 차단 결함 없음. 비차단 관찰 2건.
## 1. 범위 (항목 3)
`git diff --stat 6af4a79 e1ed270`: tests/test_issue42_skill_blocks.py 한 파일, +10/−1 (test_claude_skills_link_resolves_to_agents_skills, :240-253). 제품 코드·버전 변경 없음.
## 2. 배치별 동작 (항목 1)
검증 경로 안에 임시 .claude/skills(.gitignore:40 의 `.claude/` 로 무시됨)를 만들어 이 테스트만 돌리고 매번 지웠다. 시작 전 .claude 가 없음을 확인했고, 끝난 뒤 `.claude` 잔여물 없음·`git status --short --ignored` 비어 있음·HEAD 불변을 확인했다. 본 체크아웃의 .claude/skills 는 `ls -la` 로 읽기만 했다(스킬 8개 링크 + lib_py·lib.sh 링크 = 10개 항목, 모두 ../../.agents/skills/<이름>).
| 배치 | 결과 | 판단 |
|---|---|---|
| A. 없음 | skip | 의도대로 |
| B. 단일 링크 → .agents/skills | pass | 의도대로 |
| C. 스킬별 링크 8개 + lib.sh·lib_py 링크(본 체크아웃과 같은 배치) | pass | 의도대로 |
| C-old. 같은 배치에서 base(6af4a79) 의 테스트 | **fail** | 수정 대상 결함 재현 |
| C2. C + 여분 디렉터리(foreign-skill) | pass | 여분 허용 |
| D. stop 링크가 loop 를 가리킴 | fail (`.claude/skills/multi-agent-mux-stop -> …/multi-agent-mux-loop`) | 의도대로 |
| E. 스킬 링크 하나 없음 | fail (`… is missing`) | 의도대로 |
| F. 스킬 링크 하나가 깨짐 | fail (`… is missing`) | 의도대로 |
| G. 링크 대신 복사본 디렉터리 | fail (resolve 가 .claude 쪽) | 의도대로 — 복사본은 드리프트하므로 막는 것이 맞다 |
| H. .claude/skills 가 일반 파일 | fail (`… is missing`) | 의도대로 |
| J. 단일 링크가 다른 곳(스킬 하나)을 가리킴 | fail | 의도대로 |
| K. 빈 디렉터리 | fail | 의도대로 |
| L. 스킬별 링크가 중간 링크를 거쳐 해석됨 | pass | resolve() 가 최종 대상을 비교하므로 맞다 |
| I. **단일 링크가 깨짐** (`.claude/skills -> ../nowhere`) | **skip** | 관찰 N1 |
- SKILL_NAMES 가 비면 스킬별 루프가 공집합이라 통과하겠지만, 같은 파일의 test_inventory_covers_eight_skills 가 `len(SKILL_NAMES) == 8` 을 단언하므로(파일 상단 SKILL_NAMES 정의와 함께) 놓치지 않는다.
## 3. 엄격성 (항목 2)
- 여분 항목(lib_py, lib.sh, 다른 스킬 디렉터리)은 허용된다(C, C2). Claude Code 가 스킬로 읽는 것은 SKILL.md 를 가진 하위 디렉터리이므로 여덟 MAM 스킬이 .agents/skills 의 것인지만 보는 현재 기준이 적절하다. 다른 스킬을 같이 두는 정상 사용을 막지 않는다.
- lib.sh·lib_py 링크는 검사하지 않는데, 진입 스크립트는 `$SCRIPT_DIR/../..` 에서 lib.sh 를 찾고 없으면 `${WORKSPACE_ROOT:-$PWD}/.agents/skills/lib.sh` 로 폴백하므로(예: reconcile.sh:20-21) 테스트의 필수 조건으로 둘 이유는 없다. 문서의 호출도 모두 `bash .agents/skills/...` 경로를 쓴다.
## 4. 비차단 관찰
- **N1 깨진 단일 링크가 skip 으로 숨는다** (tests/test_issue42_skill_blocks.py:245-246): `link.exists()` 는 링크를 따라가므로 대상이 없는 단일 링크는 "없음"으로 보고 skip 한다. 이 경우 Claude Code 는 스킬을 하나도 못 읽는데 테스트는 통과(skip)한다. base 테스트도 같은 동작이었으므로 이 PR 의 회귀는 아니다. `if not link.exists() and not link.is_symlink(): skip` 으로 바꾸면 깨진 링크는 아래 resolve 비교에서 실패한다.
- **N2 실패 메시지**: G(복사본)의 메시지가 `-> <.claude 안 경로>` 로 나와 "링크가 아니라 복사본"이라는 원인이 바로 드러나지 않는다. 사소함.
## 5. 테스트
검증 경로에서 `tests/test_issue42_skill_blocks.py` 전체: **31 passed / 1 skipped** (skip = 이 테스트, 워크트리에 .claude 없음 — 정상). 배치 탐침은 §2.
[VERDICT: PASS]
@@ -0,0 +1,76 @@
# 리뷰 리포트 — Job 416d9768 (PR #48, fix/issue-45)
- 리뷰어: planner-reviewer-claude-01 (claude)
- 리뷰 identity: loop-20261005T064728Z-a4e9c8 / base c5c7eff / head 35935f9 / tree dff9db1 — 검증 경로에서 `git rev-parse HEAD 'HEAD^{tree}'` 일치, 미커밋 변경 없음
## 결론 요약
제품 동작(A: MAM 서버 복원 꺼짐, B: 자기 세션의 빈 셸만 닫기)은 이 macOS 호스트의 실제 herdr(0.9.1)에서 동작함을 확인했다. 그러나 **브리프가 지정한 검증 명령이 이 호스트(리포지토리 .venv = Python 3.9.6)에서 수집 단계에서 중단**되고, 그 파일을 빼고 돌려도 **새 테스트 5개가 macOS 에서 실패**한다. 원인은 모두 테스트의 Linux·Python 3.11 가정이며 수정은 작다. 스킬 frontmatter 가 `platforms: [linux, macos]` 이므로 차단으로 판단한다.
## 차단 (테스트 이식성)
### B1. tests/test_issue45_herdr_config.py:16 — 모듈 최상단 `import tomllib`
- Python 3.9 에서 `ModuleNotFoundError` → pytest 가 "Interrupted: 1 error during collection" 으로 **세션 전체를 중단**한다. 브리프 지정 명령(7개 파일)은 결과 0건, rc 2. 오케스트레이터의 전체 스위트도 같은 .venv 면 같은 이유로 중단된다.
- 제품 쪽(lib_py/herdr_config.py:54-57)은 tomllib 부재를 처리하는데 테스트만 강제한다. `tomllib = pytest.importorskip("tomllib")` 처럼 모듈 skip 또는 tomllib 이 필요 없는 경우만 남기는 처리가 필요하다.
- 참고: 같은 파일을 Homebrew python3.11 + 기존 venv 의 pytest(PYTHONPATH)로 돌리면 **51 passed**. 내용 자체는 정상이다.
### B2. tests/test_issue45_restore.py:227, :163 — Linux 전용 가정 (macOS 4 failed)
| 테스트 | 실패 | 원인 |
|---|---|---|
| test_a_…[plain-user-config], test_a_…[user-config-with-session-table] | FileNotFoundError `/proc/<pid>/environ` | :227 `/proc` 는 macOS 에 없다 |
| test_b_only_the_resumed_sessions_restored_shell_is_closed | `'/private/tmp/m45…' == '/tmp/m45…'` (:263) | :163 `mkdtemp(dir="/tmp")` — macOS 에서 /tmp 는 /private/tmp 링크이고 herdr 는 실경로를 보고한다 |
| test_b_resume_leaves_no_restored_shell_behind | StopIteration (:325, cwd 비교) | 같은 원인 |
- 검증: 스크래치 사본에서 **이 두 군데만** 고친 테스트(`.resolve()` 추가, `/proc` 대신 `ps -E -ww -o command= -p <pid>`)는 **5 passed**(65 s). 즉 제품은 맞고 테스트만 이식되지 않았다. 양성 대조도 이 사본에서 동작한다.
### B3. tests/test_issue9_server_env.py:157 — 테스트 안 `import tomllib`, 그리고 bootstrap 의 python3 가정
- 3.9 에서 import 로 실패. 또 3.11 로 테스트를 돌려도 bootstrap 은 `python3`(lib.sh:241, 이 호스트는 /usr/bin/python3 3.9.6)로 herdr_config 를 실행하므로 결과는 MINIMAL 이고, :170 의 `cfg == {"ui": …, "session": …}` 단언은 실패한다. `python3` 에 tomllib 이 없으면 skip(또는 MINIMAL 을 기대)해야 한다.
## 검토 항목별 판단
### 1. A 의 설정 병합 (lib_py/herdr_config.py)
- 원본 보호: 출력은 항상 output_path(:192-205)를 거친다 — 원본 자신(samefile/realpath, 별칭 포함)이나 다른 원본용·비생성 파일이면 해시 이름으로, 그것도 막히면 OSError → 쓰지 않음. 폴백(.mam)도 같은 함수(:273, R3 2회차 수정). 쓰기는 같은 디렉터리 임시 파일 + os.replace(:208-222), 원본 권한 복사(:255).
- 재귀: HERDR_CONFIG_PATH 가 생성 파일이면 2행의 `# source:` 로 원본을 따라간다(:65-92). source 행이 없으면 XDG/HOME 으로.
- 병합 정확성: tomllib parse → `session` 에 키만 덮어씀 → dumps → 재파싱이 기대값과 **정확히 같을 때만** 사용(:156-164). 인라인 표·점 표기·따옴표 키·배열은 파싱 결과(dict)로 다루므로 표기와 무관. 주석·표기 형식은 생성 파일에서 사라지지만 원본은 그대로이고 의미는 같다. 실패 시 MINIMAL + 경고.
- herdr 검사: 쓴 뒤 `herdr config check`(:169-180)가 "parse error"/"using defaults" 를 내면 MINIMAL 로 재시도, 그것도 거부되면 None → bootstrap 이 경고하고 예전처럼(복원 켜짐) 기동(lib.sh:241-245). 단위 테스트가 실제 herdr 로 이를 확인(3.11 에서 51 passed).
- 사용자 디렉터리 흔적: `~/.config/herdr/mam-server-config.toml`(또는 해시 이름) 1개, 중단 시 `.mam-server-config.*.tmp` 가능. create SKILL.md:120-121 에 위치가 적혀 있다.
- 비차단 D1 (아래): python3 < 3.11 호스트에서는 병합이 일어나지 않는다.
### 2. 적용 경로
- MAM 이 서버를 띄우는 곳은 shim 의 headless bootstrap 한 곳(lib.sh:261 `"$REAL_HERDR" --session "$_MAM_SESSION" server`)뿐이고 A 가 여기에 걸려 있다. create·resume·delegate 는 모두 shim 을 거친다. 다른 `server` 기동은 grep 으로 찾지 못했다. MAM_NO_AUTOSTART 호출자(status/reconcile)는 서버를 띄우지 않는다(lib.sh:196-201).
- 걸리지 않는 경로: 사용자가 `herdr session attach`/`herdr --session X` 로 직접 띄운 서버, 기본 `default` 서버, 이미 떠 있는 서버. create SKILL.md:121 이 "rebuilt each time MAM starts the server … next started", "A server you start yourself (`herdr session attach`) does not get it" 로 적고 있다(합의된 한계와 일치).
### 3. B 의 닫기 조건 (lib_py/herdr_restore.py)
- 조건(:54-65): 세션 id 비어 있지 않음, 서버가 default 아님, `agent` 없음, `agent_session.value == id`, 전경 = shell_pid(shell_pid 없으면 False). 사용자 pane(agent_session 없음)·다른 세션·전경 프로세스 있음·에이전트 살아 있음은 제외 — 실제 서버 테스트(사본)에서 사용자 셸·Z(실행 중)·X2(sleep 60)가 남고 X·Y 만 닫힘을 확인.
- fail-safe: herdr 오류·JSON 오류는 예외 → exit 1(:79-83) → resume 은 경고만(resume_session.sh:240-242) 하고 진행. default 서버는 herdr 를 부르지도 않는다(test_b_default_server_is_never_touched 통과). Class A fresh-spawn(UUID 없음)은 즉시 반환.
### 4. 테스트의 격리·결정성
- test_issue45_restore 는 HOME 을 `/tmp/m45*` 로, 세션을 `mamt45-<pid>-<4hex>` 로 두고 HERDR_*·XDG_CONFIG_HOME·HERDR_CONFIG_PATH 를 지운다(:42-44, :161-168). 정리는 그 세션 이름·그 루트 경로로만 pkill(:186-195). conftest 도 HERDR_CONFIG_PATH·XDG_CONFIG_HOME 을 지운다.
- 실행 전후 확인(실패 실행 3회, 사본 실행 3회 모두): herdr 프로세스는 기존 `multi-agent-mux` 서버·attach 클라이언트뿐(잔류 없음), `/tmp/m45*` 없음, `~/.config/herdr` 목록·config.toml md5·sessions 목록 불변.
- 양성 대조: 사본에서 lib.sh 의 `HERDR_CONFIG_PATH` 주입을 끄면 test_a 가 `--resume <id>` 로 실패(#45 재현), resume 의 close 호출을 끄면 test_b_resume 가 pane 2개로 실패. 테스트가 수정에 실제로 의존한다.
- 결정성: 폴링 + 고정 대기(최대 수 초)라 느린 호스트에서 다소 민감할 수 있으나 3회 반복 모두 같은 결과였다.
### 5. 문서·인벤토리
- 버전: lib.sh·eager.py·SKILL.md 8개·VERSIONS 가 4.8.5 로 일치(test_version_consistency 통과).
- skill_code_blocks.json: blocks 의 (skill, class, kind) 순서 동일, create 블록 4개의 start/end 이동과 resume removed 항목의 evidence 줄 번호(+8) 이동뿐. 분류 변경 없음.
- **D1 (비차단, 문서)**: VERSIONS 4.8.5 항목 1과 create SKILL.md:120 은 서버 설정을 "사용자 설정 + 키"로 설명하지만, bootstrap 은 `python3`(lib.sh:241)로 실행되므로 python3 < 3.11 인 호스트(이 macOS 의 /usr/bin/python3 3.9.6)에서는 **항상 키 하나만 있는 파일**로 기동하고 사용자 herdr 설정(예: default_shell·키 바인딩)이 MAM 서버에 적용되지 않으며, 기동마다 경고가 나온다. 복원은 꺼지므로 안전하지만, 이 한계를 문서에 적거나 3.11+ 인터프리터를 찾는 편이 좋다. VERSIONS 의 "실패 시 키 하나만" 목록에도 Python 3.11 미만이 빠져 있다.
### 6. purge 기준 실패
검증 경로에서 tests/test_issue10_13_purge.py **23 passed**(macOS). 이 PR 과 무관하며 "12 실패"는 Linux 기준이다.
### 7. 경계(#17)
새 판정(병합·검사·출력 경로, 닫기 조건)은 모두 Python(herdr_config.py, herdr_restore.py)에 있다. bash 쪽 추가는 호출과 빈 값 경고(lib.sh:241-245, resume_session.sh:240-242)뿐이다.
## 테스트 실행 요약
| 실행 | 결과 |
|---|---|
| 브리프 지정 7개 파일 (pr-48 .venv, Python 3.9.6) | **수집 오류로 중단, 0건 실행, rc 2** (B1) |
| herdr_config 제외 6개 파일 | **5 failed** / 192 passed / 1 skipped (B2 4건, B3 1건; skip = .claude/skills, 워크트리 정상) |
| test_issue45_herdr_config.py (python3.11) | 51 passed |
| test_issue45_restore.py 이식성 2곳만 고친 사본 | 5 passed; A·B 수정을 끄면 각각 실패 |
| test_issue10_13_purge.py | 23 passed |
MQTT_* unset, .mam.env 미export. 실제 .mam/jobs 에 새 항목 없음.
## 수정 제안 (최소)
1. test_issue45_herdr_config.py:16 → `tomllib = pytest.importorskip("tomllib")`.
2. test_issue45_restore.py:163 → `Path(tempfile.mkdtemp(...)).resolve()`; :227 → `/proc` 가 없으면 `ps -E -ww -o command= -p <pid>` 로 확인(또는 그 단언만 Linux 한정).
3. test_issue9_server_env.py:157 → bootstrap 이 쓸 `python3` 에 tomllib 이 없으면 skip 하거나 MINIMAL 을 기대.
4. (비차단) D1 문서화.
[VERDICT: NOT PASS]
@@ -0,0 +1,43 @@
# 리뷰 리포트 — Job 4566e690 (Issue #17 후속, fix/issue17-followups 56d0bfd..8241bd5)
- 리뷰어: planner-reviewer-claude-01 (claude)
- 리뷰 identity: loop-20261007T224638Z-cf1efb / base 56d0bfd / head 8241bd5 / tree efef640 — 검증 경로에서 `git rev-parse HEAD 'HEAD^{tree}'` 일치, 미커밋 변경 없음
- 커밋 6개: 25c9970·f19f940(항목 1), a670ef5(항목 2), d6cb019(항목 3), c90a0af(항목 4), 8241bd5(테스트 환경 고정). 버전·VERSIONS.md 변경 없음(브리프대로).
## 결론 요약
항목 1~4 가 요구대로 반영됐고, 정상 경로의 종료 코드·출력·레지스트리·이벤트는 변하지 않았다. 추가 테스트 13건은 수정 전 코드에서 실제로 실패한다(255·127·2 등 예측된 코드로). 항목 1 에는 브리프 문구("테스트 전제 수정")를 넘는 **제품 코드 변경**(herdr_restore.py 의 placeholder 닫기)이 포함되는데, 실제 herdr 0.9.3 에서 그 변경 없이는 테스트도 resume 의 닫기도 성립하지 않음을 확인했으므로 정당한 범위 확장으로 판단한다(§1). 차단 결함 없음. 비차단 관찰 3건(§6).
## 1. 항목 1 — herdr 0.9.3 과 test_b
- 브리프의 전제 "닫기 동작은 cwd 를 보지 않는다 → :275 만 두 경로 허용" 은 불완전했다. herdr 0.9.3 은 디렉터리가 사라진 pane 을 **프로세스 없는 placeholder**(`restore_error` 설정)로 복원하고 `pane process-info` 는 `pane_not_found` 로 rc 1 을 낸다. 실제 서버 대조: 스크래치 사본에 **이전 herdr_restore.py** 만 넣고 test_b 를 돌리면 `close-restored-shells: Command […'pane','process-info','--pane','w2:p1'] returned non-zero exit status 1` 로 실패(1 failed, 11.8 s) — 즉 옛 코드는 Y 의 placeholder 에서 예외를 내 resume 의 닫기 단계 전체가 rc 1 로 끝나고(래퍼는 경고만), 아무 pane 도 닫지 못한다.
- 수정(herdr_restore.py:58-66 `_nothing_running`): `_shell_in_foreground` 가 CalledProcessError 를 내면 **pane 에 `restore_error` 가 있고 출력에 `pane_not_found` 가 있을 때만** "실행 중인 것 없음"으로 보고, 그 외 실패는 그대로 올린다. 두 조건이 모두 herdr 자신이 붙인 표지라 사용자 pane·다른 세션 pane 이 이 경로로 닫힐 수 없다(agent_session 일치 조건 :83 은 그 앞에서 여전히 적용). 단위 테스트 2건이 "restore_error 없음 + pane_not_found", "restore_error 있음 + 다른 오류" 를 모두 예외 유지로 고정한다. fail-safe 방향(닫는 쪽이 아니라 두는 쪽) 유지.
- `_herdr` 는 `subprocess.run(..., capture_output=True, check=True)` 이므로 CalledProcessError.stderr/.output 이 채워진다(:46-49) — 판정에 쓰는 문자열이 실제로 들어온다.
- test_issue45_restore.py:267·:275-277 주석·단언이 0.9.1($HOME)/0.9.3(원래 경로) 둘 다 허용. 다른 문서의 "$HOME 으로 복원" 주장: SKILL.md 들에는 없음(create:120, resume:63 은 "empty shells" 만 말함), VERSIONS.md 과거 항목은 브리프대로 제외.
- 실행: 이 Mac(herdr 0.9.3)에서 tests/test_issue45_restore.py **5개 모두 통과, skip 0**.
## 2. 항목 2 — N2 fail-closed
- 래퍼(:103-127): `_decision_failed` 는 stderr 에 사유를 적고 `exit 1`; `_decided <var> <sub> …` 는 rc≠0 또는 빈 출력이면 실패; `_exit_for` 는 답이 `^[0-9]+$` 가 아니면 실패. 다섯 `exit "$(_decide exit-code …)"` 가 `_exit_for` 로, 네 `sub-status` 대입과 `finalize-begin` 이 `_decided` 로, `finalize-end` 는 rc 검사(:856-857), `cmd_wait` 의 wait-rc 는 `_decided` + 숫자 검사(:1070-1071). 모든 호출이 서브셸 밖(직접 호출)이라 `exit 1` 이 래퍼를 끝낸다; `{ …; } 4>&1` 그룹도 서브셸이 아니다.
- 정상 경로 불변: `_decided` 는 같은 명령의 같은 한 줄 출력을 변수에 넣고, `finalize-end` 의 "소유자 사라짐 → rc 0·빈 답" 은 전과 같이 FINAL_JOB_STATUS="" 로 남는다(의도된 기존 동작). test_f_the_normal_path_is_unchanged(실제 래퍼, human 잡, 닿지 않는 브로커 → rc 3, 마지막 줄 = 로그 경로, 오류 문구 없음) 통과. 옛·새 비교 테스트(test_c_*) 전부 통과.
- rc 로만 읽는 호출: `subscriber-up` 실패(모듈 깨짐) → "subscriber died early" 경로 → `_decided sub-status` 가 실패 → exit 1(fail-closed, OK). `stall-watch` 실패 → `|| return 0` 으로 **감시만 꺼진 채 진행**(:718). 브리프가 "범위에 넣지 않은 것은 이유를 적으라" 했는데 리뷰어에게 그 이유는 보이지 않는다 — 관찰 N1. #30 감시자는 notify-only 라 잡 종결에는 영향이 없어 비차단.
- 추가 테스트가 수정 전 코드에서 실패하는가: 이전 래퍼(56d0bfd)를 넣은 사본에서 test_f_* 11건 + test_s_no_printed_decision_is_read_raw 모두 **실패** — exit-code 는 `exit: : numeric argument required` 로 **255**, 인터프리터 부재는 **127**, finalize-begin 빈 답은 finalize-end argparse `--evidence-until: invalid int value: ''` 로 **2**. 세 가지 모두 브리프가 금지한 종료 방식이며 새 코드는 전부 1 로 끝난다.
- 이식성: `/bin/bash -n` 통과(3.2), `printf -v`·`[[ =~ ]]` 는 3.2 에서 지원. 테스트는 `/bin/sh` 셸 스크립트 대역과 `sys.executable` 만 쓴다.
## 3. 항목 3 — N1 문구
wrapper.py:24-29 가 "이 모듈은 stdlib 만 import 하지만 `python -m lib_py.delegation.wrapper` 는 패키지 `__init__` 을 먼저 실어 paho 가 로드되며, 래퍼의 $PY 는 원래 paho 가 필요하다"로 정정. 실행 경로 변경 없음(diff 상 docstring 만).
## 4. 항목 4 — N3 주석
lib_int.sh:9-10 에 "PATH 의 python3 를 쓴다(단독 source 라 $PY 없음); 없으면 mam_int/mam_int_knob 이 아무것도 출력하지 않는다(rc 127)" 추가. 코드 불변.
## 5. 테스트 실행 (검증 경로, 워크트리 .venv Python 3.9.6, paho 있음, `env -i` 로 HERDR_*·MAM_*·MQTT_*·WORKSPACE_ROOT 제거)
| 실행 | 결과 |
|---|---|
| tests/test_issue45_restore.py, test_issue45_restore_unit.py, test_issue17_item4_decisions.py, test_issue33_budget.py | **310 passed / 0 failed / 0 skipped** (425 s) |
| 새 테스트 16건, 이전 래퍼·이전 herdr_restore.py 사본 | **13 failed** / 3 passed (실패 = F 11 + 정적 가드 + placeholder 단위) |
| test_b (실서버), 이전 herdr_restore.py 사본 | 1 failed (pane_not_found 예외) |
격리: 실행 전후 herdr 프로세스는 기존 `multi-agent-mux` 서버·attach·오케스트레이터 `agent wait`(pid 만 교체)뿐, `/private/tmp/m45*` 없음, `~/.config/herdr` sessions·config.toml md5 불변. 실제 .mam/jobs 항목 수 불변. 루프백 bind 제약 없음(브로커 테스트 정상 실행).
## 6. 비차단 관찰
- **N1 stall-watch 판정 실패 시 조용히 감시 생략**(래퍼 :718 `|| return 0`): 모듈이 깨지면 stderr 에 아무 말 없이 stall watcher 가 꺼진다. 종결·레지스트리·이벤트에는 영향이 없어 fail-closed 요구 밖이지만, `_decide` 의 rc 가 1(판정 "감시 안 함")인지 그 밖(실패)인지 구분해 후자는 경고 한 줄을 남기면 진단이 쉬워진다. 브리프가 요구한 "범위 제외 이유" 가 리포트에 있는지 오케스트레이터가 확인하길 권한다.
- **N2 `_env` 가 상속 WORKSPACE_ROOT 를 비우지 않음**(tests/test_issue17_item4_decisions.py:224-230): HERDR_/MAM_/SKS_ 만 걷어낸다. 새 테스트(:659-660, :692)는 WORKSPACE_ROOT 를 명시하지만, 래퍼를 source 하는 기존 `_run_both` 4곳(:246, :372, :561, :586, PR #51 유래)은 `_env(work, …)` 만 써서 에이전트 pane 에서 돌리면 그 pane 의 WORKSPACE_ROOT 로 lib.sh 가 초기화된다 — 브리프가 적은 사고(상속된 WORKSPACE_ROOT 로 다른 워크스페이스의 shim 재작성)와 같은 경로다. `_env` 에서 WORKSPACE_ROOT 를 제거하고 tmp_path 로 고정하면 한 줄로 막힌다. 이 델타가 만든 문제가 아니라 비차단.
- **N3 docstring 줄바꿈**(herdr_restore.py:7-8): "A pane keeps the\n`agent_session` its agent\nreported" 로 어색하게 끊겨 있다. 내용 문제 없음.
[VERDICT: PASS]
@@ -0,0 +1,14 @@
{
"loop_id": "loop-20261017T145452Z-6c5aa6",
"base_sha": "26e2c43753169671b600d03274901dbe203988dd",
"head_sha": "de6f3fc81f8c33a9d2335320742dc5a47f566cda",
"tree_sha": "ffc291e6fc93822070f2b9f1d17009e83f176fcf",
"review_commit": "de6f3fc81f8c33a9d2335320742dc5a47f566cda",
"reviewer": "planner-reviewer-claude-01",
"job_id": "48d0a01c",
"verdict": "PASS",
"transport": "herdr",
"evidence": "review-tree",
"isolation": "worktree",
"verified_at": "2026-10-17T15:16:54Z"
}
@@ -0,0 +1,52 @@
# 리뷰 리포트 — PR #59 3차 (Issue #31) / job 48d0a01c
- 리뷰어: planner-reviewer-claude-01 (Opus 5.5)
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261017T145452Z-6c5aa6/1`
- identity: HEAD `de6f3fc81f8c33a9d2335320742dc5a47f566cda`, tree `ffc291e6fc93822070f2b9f1d17009e83f176fcf` 일치. 트리는 clean이고 `.mam` 없음(테스트 후에도 동일).
- 범위: 26e2c43..de6f3fc 전체. 2a8d738 이후 변경은 de6f3fc 한 커밋으로, `tests/test_issue31_purge_residual.py` 의 +3/−1 뿐이다. `git diff 2a8d738 HEAD -- .agents` 는 비어 있다.
## 결론
2차의 유일한 차단(T1: macOS `/bin/sh` 의 단일 명령 exec 로 보유자 argv 에서 uuid 가 사라지던 문제)이 해결됐다. 제품 코드는 2a8d738 과 바이트 단위로 같으므로, 2차에서 이 Mac 에서 확인한 내용이 그대로 유효하다. 확인한 항목은 비-UUID 거부, 링크 경계, 유한하지 않은 startedAt, UTC lstart, exit 4 안내, 삭제 범위, 실제 `~/.claude`·`/private/tmp/claude-501` 레이아웃 정합이다. 판정은 **PASS**.
## (a) de6f3fc 검증
1. **보유자 argv 에 uuid 가 남는다.**
- 새 보유자는 `[sys.executable, "-c", "import time; time.sleep(120)", conv]` 이다(`tests/test_issue31_purge_residual.py:317`).
- Python 인터프리터는 `-c` 뒤 인자를 `sys.argv[1]` 로 받을 뿐 exec 로 자신을 바꾸지 않으므로, `ps` 의 args 에 소문자 `conv` 가 그대로 남는다.
- `subprocess.Popen` 은 자식의 exec 성공을 확인한 뒤에 반환하므로, 이어지는 `time.sleep(0.05)` 시점에는 argv 가 이미 보인다(경합 없음).
- `sys` 는 파일 머리(`:22`)에서 이미 import 되어 있다. 같은 패턴인 `test_issue10_13_purge.py` 의 `_holder()` 와도 일관된다.
2. **upper/mixed 에서 통과한다.** 검증 경로에서 `-k any_spelling` 을 5회 반복 실행해 5회 모두 `2 passed` 였다. 관련 모듈 전체 실행에서도 통과했다(아래).
3. **철자 처리 회귀를 여전히 잡는다.** `git clone` 사본에서 claude 어댑터를 한 군데씩 변형해 test 파일 전체를 돌렸다.
- `conversation_in_use` 의 argv 검색을 `{canon, uuid}` 에서 `{uuid}` 로 바꿈(소문자 정규화 제거): 2 failed (upper, mixed). 실패 지점은 정확히 새 보유자 단언 `:320`, `AssertionError: a command line naming the lowercase id` 이다. 즉 수정된 보유자가 이 회귀를 잡는 바로 그 장치다.
- `_session_files(canon)` 을 `_session_files(uuid)` 로 바꿈: 2 failed (세션 파일 경로 단언에서).
- `_canonical` 의 `.lower()` 제거: 2 failed.
- 세 변형 모두 나머지 65개는 통과했으므로, 실패가 해당 테스트에 국한된다.
4. **정리 동작이 유지된다.**
- `try/finally: named.kill(); named.wait()` 구조는 그대로다(`:318-323`).
- 변형 실행(일부러 실패시킨 경우 포함)이 끝난 뒤 `ps -axo pid=,args=` 에서 `python … import time; time.sleep(120)` 프로세스는 0개였다. argv0 가 python 인 행만 세어 자기 자신과의 매칭은 배제했다.
## (b) 전체 판정을 위한 재확인
- 제품 파일(`.agents/` 전체)은 2a8d738 과 동일하므로, 2차 리뷰(job 778fc866)의 확인 결과를 그대로 승계한다. 다시 돌린 것은 위 변형 대조와 아래 관련 모듈이다.
- 알려진 비차단 세 가지는 브리프대로 병합 차단이 아니다. 내용은 2차 리포트에 적은 그대로다.
- 링크된 `projects/<key>` 아래 jsonl 은 purge 되지 않고 residual 로만 보고된다.
- 소문자 질의는 대문자 argv 보유자를 보지 못한다.
- `purge_artifacts` 를 직접 호출하면 in-use 게이트를 거치지 않는다.
## 테스트
검증 경로에서 `env -i`(HERDR_*·WORKSPACE_ROOT·MAM_*·DELEGATE_JOB_LOGS_DIR·CLAUDE_CONFIG_DIR 미설정), 스크래치패드 basetemp, `PYTHONDONTWRITEBYTECODE=1 -p no:cacheprovider` 로 실행했다.
- 실행 모듈: `test_issue31_purge_residual, test_issue10_13_purge, test_a4_adapter_contract, test_issue42_skill_blocks, test_issue55_agent_homes, test_orc_onboard, test_uuid_target, test_tier1_unit, test_tier2_component`
- 결과: **332 passed, 1 skipped, 1 error** (624 s). 2차의 2 failed 는 사라졌다.
- 1 error 는 `test_tier2_component.py::test_comp_codex_create_and_reconcile_and_stop` 의 **teardown 에서 #55 가드**가 낸 것으로, 브리프가 밝힌 알려진 오탐이다. 판정 근거로 쓰지 않고 보고만 한다.
- 가드가 잡은 경로: `~/.gemini/antigravity-cli/conversations/0dd4a87f-6170-472b-886a-eef6e099b375.db-shm` 과 `.db-wal`.
- 읽기 전용 확인 결과: 이 DB 는 다른 곳에서 돌고 있는 실제 `agy` 프로세스(pid 66733)가 열고 있다(`lsof -t`). WAL 이 자라는 중이다(mtime Oct 18 00:06, 크기 19.6 MB). 테스트 산출물이 아니다.
- 이 테스트는 2차 실행에서는 통과했고, 이번 커밋은 tier2 와 무관한 테스트 한 줄만 바꿨다.
## 비차단 메모
- 2차 리포트의 비차단 사항(`projects/<key>` 링크 시 동작 문서화 등)은 그대로다. 병합 후 별도 이슈로 다뤄도 된다.
[VERDICT: PASS]
@@ -0,0 +1,14 @@
{
"loop_id": "loop-20261013T025803Z-c69408",
"base_sha": "42e2a7ea9bd3e2770ed8626d789193428ca3aa8b",
"head_sha": "3a30d7da894cdac26b7ec054b4e66fe9fa3dc8db",
"tree_sha": "bd43e4642f0109091f5f497b2703dcbaf8e2616b",
"review_commit": "3a30d7da894cdac26b7ec054b4e66fe9fa3dc8db",
"reviewer": "planner-reviewer-claude-01",
"job_id": "495a1373",
"verdict": "PASS",
"transport": "herdr",
"evidence": "review-tree",
"isolation": "worktree",
"verified_at": "2026-10-13T03:32:19Z"
}
@@ -0,0 +1,34 @@
# 테스트 격리 수정(u1e 의 WORKSPACE_ROOT) 리뷰 — 커밋 3a30d7d (job 495a1373)
- 리뷰어: planner-reviewer-claude-01 (Reviewer)
- 리뷰 identity: loop-20261013T025803Z-c69408 / base 42e2a7ea .. head 3a30d7da (커밋 1개, 테스트 2 파일) / tree bd43e464
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261013T025803Z-c69408/1` — HEAD·tree 일치, `git status --porcelain` 0 줄, **`.mam` 없음**(시작·종료 모두). 본 체크아웃 HEAD 42e2a7e(main) 변동 없음.
- 실행 안전: 모든 실행은 `env -i`(PATH/HOME/LANG/TMPDIR 만)로 브리프의 `-u` 목록을 포함한 라우팅 변수를 비웠고, basetemp 는 scratchpad 아래(이름에 "shim" 없음). 수정 전 재현은 검증 경로를 `git clone --no-hardlinks` 한 임시 복사본에서만 했고 거기 생긴 `.mam` 은 지웠다. 검증 경로에서 돌린 것은 W1/W2 뿐이며(W1 은 체크아웃을 tmp 로 복사해 그 안에서 노드를 돌린다), 끝난 뒤 검증 경로에 `.mam` 이 없음을 확인했다. 실제 `.mam`·`.mam.env`·모니터·실제 서버·에이전트 홈 미접촉.
## 1. 원인과 수정
- 원인(1차 README 리뷰에서 재현한 그대로): `test_u1e` 는 래퍼를 `subprocess.run(["bash", WRAPPER, "submit", …, "--dry-run"], env=dict(os.environ, HOME=…, MAM_ENV_FILE=…), cwd=tmp_path)` 로 부르는데, 래퍼가 source 하는 `lib.sh` 는 `WORKSPACE_ROOT` 가 없으면 체크아웃을 기본값으로 삼고(`lib.sh:26`) `_init_herdr_isolation` 이 `$WORKSPACE_ROOT/.mam/shim/herdr` 를 쓴다(`lib.sh:150-154`). conftest 의 `pytest_configure` 는 다른 체크아웃을 가리키는 `WORKSPACE_ROOT` 만 떨어뜨리므로 pane 밖에서는 변수가 없고, 체크아웃이 기본값이 된다.
- 수정(`tests/test_issue50_dispatch.py:167-170`): env 에 `WORKSPACE_ROOT=str(tmp_path)` 추가 → shim 은 `tmp_path/.mam/shim/herdr` 에 쓰인다. `tests/test_issue17_workspace_root_isolation.py:43`: 이 노드를 W1 `NODES` 에 추가. 두 파일 `py_compile` 통과. 같은 파일의 다른 테스트는 `decide.*` 를 in-process 로 부르므로(`_register`/`_edit`/`_event` 헬퍼) shim 을 쓰지 않는다 — 아래 §2 의 파일 전체 실행이 이를 확인.
## 2. 실행 결과
| 실험 | 결과 |
|---|---|
| **수정 전 복사본**(검증 경로 clone + `test_issue50_dispatch.py` 를 base 42e2a7e 로 되돌림): u1e 단독 | 1 passed, 복사본에 **`.mam/shim/herdr` 생성** — 원인 재현 |
| **수정 전 복사본**: W1(새 NODES 포함) | **FAIL**: `AssertionError: tests wrote into the checkout they ran in: ['.mam/shim', '.mam/shim/herdr']` — W1 이 이 노드로 회귀를 잡는다(복사본 안의 tmp 복사본에만 쓰였고 복사본 자체에는 `.mam` 없음) |
| **head, 검증 경로**: `test_issue17_workspace_root_isolation.py` (W1+W2) | **2 passed**, 검증 경로 `.mam` 없음 |
| **head 복사본**(`git archive`): `test_issue50_dispatch.py` 전체 | **49 passed**, 복사본 `.mam` 없음 |
W1 이 잡는 원리: W1 은 복사본에 `WORKSPACE_ROOT=<복사본>` 을 주고 노드를 돌린다. 수정 전 u1e 는 `os.environ` 을 그대로 물려주므로 래퍼가 그 복사본에 shim 을 쓰고, 수정 후는 `tmp_path` 로 덮어써 복사본에 아무것도 남지 않는다. pane 밖(변수 없음)에서도 lib.sh 의 체크아웃 기본값 때문에 같은 결과이므로 두 환경 모두를 대표한다.
## 3. 같은 결함이 남은 테스트가 있는가
- 정적: 래퍼·`lib.sh`·`run_loop.sh`·`create_session.sh`·`reconcile.sh`·`stop/resume_session.sh` 를 subprocess 로 부르는 테스트 파일 59 개를 추려 `mam_sandbox`/`own_workspace_root`/`WORKSPACE_ROOT` 참조 여부를 봤다. 참조가 하나도 없는 7 파일과, 참조가 적어 테스트별 누락이 있을 수 있는 18 파일(W1 NODES 에 없는 것 위주)을 골랐다.
- 경험적: 그 **25 파일을 head 의 `git archive` 복사본에서 파일마다 따로** 돌리고 매번 복사본의 `.mam` 을 봤다 — `test_b7_diff_untracked`, `test_deploy_freshness`, `test_deploy_layout`, `test_issue42_skill_blocks`, `test_issue45_herdr_config`, `test_issue5_phase0_contracts`, `test_local_mqtt`, `test_issue14_delivery_ack`, `test_issue18_liveness_contract`, `test_issue6_turn_handshake`, `test_issue26_envfile`, `test_issue26_ints`, `test_issue33_budget`, `test_layout`, `test_workspace_scope`, `test_a4_adapter_contract`, `test_issue45_restore`, `test_sanitize_and_mock_errors`, `test_sanity`, `test_issue12_registry_secrets`, `test_issue21_acceptance`, `test_issue15_registry_isolation`, `test_issue20_state_isolation`, `test_issue36_prompt_transport`, `test_issue34_job_tree`: 전부 통과(skip 은 tomllib/nats-docker 류), **`.mam` 생성 0 건**.
- 이전 리뷰들에서 같은 방식으로 돌린 파일들(leak·startup_path·shim_contract·issue17/9/45, scenario4, pane_lock, issue50_dispatch_herdr 등)도 검증 경로에 `.mam` 을 남기지 않았고, `.mam/shim` 이 생긴 경우는 모두 `test_issue50_dispatch.py` 가 포함된 실행뿐이었다(u1e). 즉 이 결함은 u1e 하나였고, 수정으로 닫혔다.
## 4. 잔류물
- clone 복사본·archive 복사본·basetemp·로그는 scratchpad 아래. 검증 경로는 깨끗(`.mam` 없음, `git status` 0 줄). #55 가드 오탐은 이번 실행에서 나오지 않았다.
## 결론
원인 진단이 맞고, `WORKSPACE_ROOT=tmp_path` 가 정확한 최소 수정이며, W1 은 수정 전 트리에서 이 노드로 실패하고 수정 후 통과한다. 다른 테스트에서 같은 누출은 25 파일의 파일별 실행으로 재현되지 않았다.
[VERDICT: PASS]
@@ -0,0 +1,131 @@
# 🔍 Review Report — PR #37 병합 전 독립 리뷰 (job `4ab12ae2`)
- **Reviewer**: `planner-reviewer-claude-01` (role: planner,reviewer) · 2026-09-24
- **대상**: PR #37 `fix/issue-36` **@ `59be1c8`**, base `main 1debc7d` (v4.7.2). 6커밋, 11파일 `+765/−30`, `git diff --check` 클린.
- **검증 경로**: `/var/folders/.../T/mam-review/loop-20260924T080014Z-2b3df0/1`. **identity 확인**: `HEAD = 59be1c89…cb49`, `HEAD^{tree} = a009c906…fab7` — 브리프와 일치.
- **안전 규칙 준수**: 검증 경로·소스 워크트리(`multi-agent-mux-wt/pr-37`)·본 체크아웃(`main 1debc7d`) 모두 수정·커밋·checkout 없음. 실험은 scratchpad 임시 디렉터리(uuid4)에서만(래퍼는 `--dry-run` + `--agent human` + 도달 불가 브로커, 러너는 in-process 스텁), pytest 는 `AGENT_PYTHON_BIN` + `--basetemp=<검증 경로 밖>`. 실제 herdr·세션·`.mam`·데몬 조작 없음, push·merge·PR 상태 변경 없음.
## 결론 요약
**병합을 막을 결함은 없다.** 프롬프트는 러너·래퍼·lib.sh 도우미·옛 셸 러너·create 온보딩 다섯 경로 모두에서 파일로 간다(`grep -rn -- '--prompt '` 의 잔존 호출은 전부 셸 **함수** 인자이거나 문서/기본값이다). 비차단 지적 4건(H1–H4). 그중 **H1 은 재현한 실제 결함**이다: 파이썬 러너는 프롬프트 파일 **쓰기**가 실패하면(ENOSPC 등) 부분 파일을 `.mam` 에 남기고 예외를 그대로 던진다 — PR 이 셸 쪽 두 곳에서 고친 바로 그 부류가 러너에는 빠져 있다. 디스크 고갈 급의 경계 상황이라 차단으로 보지 않았지만, 릴리스 커밋이나 즉시 후속에서 세 줄로 닫을 것을 권한다.
## 1. 전달 경로의 완전성 (항목 1)
| 경로 | 이전 | 이제 | 근거 |
|---|---|---|---|
| 파이썬 러너 `_run_cmd` | `--prompt <text>` argv | `.mam/prompt-<loop_id>-XXXX.md` 에 쓰고 `--prompt-file` | `runner.py:467-502` |
| 래퍼 `submit` → `registry.py register` | `--prompt "$PROMPT"` | 파일로 왔으면 `--prompt-file` 그대로 전달, 텍스트로 왔으면 종전대로 | `multi-agent-mux-delegate-job:186-208` |
| 래퍼 loop 타입의 회차 `update` | `--prompt "$current_prompt"` | `$job_dir/prompt.md` 에 쓰고 `--prompt-file` | `:405-450` |
| `lib.sh` `delegate_submit_job` / `_delegate_submit_job_legacy` (제출 도우미 11곳의 종착) | argv | `_delegate_prompt_file` 로 `.mam/prompt-submit-XXXXXX` 에 쓰고 `--prompt-file`, 성공·실패 모두 `rm -f` | `lib.sh:2038-2046, 2049-2064, 2160-2174` |
| `lib_py/delegation/cli.py submit` | `--prompt` 필수 | `--prompt`/`--prompt-file` 상호 배타, register 로 그대로 전달 | `cli.py:29-33, 64-66` |
| 옛 셸 러너 `MAM_LOOP_PY=0` | `delegate_job_safe … --prompt "<text>"` (함수 인자) | 함수 안에서 `--prompt` 를 `.mam/prompt-shloop-XXXXXX` + `--prompt-file` 로 바꿔 래퍼 exec | `run_loop.sh:196-221`. `run_loop.sh` 의 잔존 `--prompt "…"` 11곳은 이 **함수** 호출이라 exec 을 거치지 않는다 |
| `create_session.sh --submit-job` | 주입문에 과제 본문 포함 | 과제는 `<jobs>/<id>/brief.md` 에 쓰고 pane 에는 **경로만** | `create_session.sh:625-646` |
### 1-1. 래퍼의 파일 처리 (실측, `--dry-run --agent human`)
- 상대 `--prompt-file "my prompts/p 1.md"` 는 **호출자 cwd 기준**으로 절대화된다(`:191`, `cd "$WORKDIR"` 이전) — `--workdir` 가 다른 곳이어도 찾는다. 상대 `--registry-dir "reg dir"` 는 종전대로 `--workdir` 기준이다(기존 의미 유지). 공백 경로 모두 안전.
- 등록된 `prompt` 는 **바이트 단위로 동일**(`'line1\nline2\n\n'`, 후행 개행 보존) — registry 가 파일을 직접 읽기 때문. 래퍼의 `$PROMPT` 변수(`$(<file)`)는 후행 개행을 잃지만 그것은 브리프 본문에만 쓰인다(H4).
- 래퍼는 호출자의 파일을 **지우지 않는다**(소유권은 호출자). `--prompt` 와 `--prompt-file` 동시 → 거부, 없는 파일 → `cannot read --prompt-file <절대경로>` 거부.
- 읽는 시점: register 는 래퍼의 첫 단계(`:207`)이고 래퍼는 잡이 끝날 때까지 블록하므로, 러너·lib.sh·셸 러너가 **래퍼 반환 후** 지우는 순서는 안전하다.
### 1-2. 임시 파일의 위치·권한·정리
| 생성자 | 위치 | 권한 | 정리 |
|---|---|---|---|
| 러너 | 제어 루트 `.mam/` (`self.mam_dir`) | `NamedTemporaryFile` → 0600 | `finally: os.unlink` — 성공·실패·**OSError(exec 실패)** 모두 (`:498-502`) |
| lib.sh | `${WORKSPACE_ROOT}/.mam/` | `mktemp` → 0600 | 제출 뒤 `rm -f`; **쓰기 실패 시 `rm -f` 후 return 1** (`lib.sh:2042`) |
| 셸 러너 | `$MAM_REAL_ROOT/.mam/` | `mktemp` → 0600 | 래퍼 반환 뒤 `rm -f`; 쓰기 실패 시 제거 + 원인 로그 + return 1 (`:201-205`) |
| create | `<jobs>/<id>/brief.md` (잡 디렉터리, 보존 대상) | umask | 실패 시 잡 `error` + exit 1 (`:632-637`) |
### H1 (비차단이지만 실제 결함, 재현) — 러너의 쓰기 실패는 부분 파일을 남기고 예외를 던진다
`runner.py:472-477` 의 `with NamedTemporaryFile(...) as fh: fh.write(p_text)` 는 `try` **바깥**에 있다. 파일이 만들어진 뒤 `write` 가 실패하면(ENOSPC·EIO) `finally` 의 `unlink` 에 닿지 않는다. 검증 경로의 러너를 in-process 로 띄우고 `write` 가 `OSError(28)` 을 내도록 감싼 결과:
```
RAISED: OSError [Errno 28] No space left on device ← delegate_job 밖으로 그대로 전파(트레이스백, 126 처리 아님)
leftover prompt files in .mam: ['prompt-loop-…-2ussg8e6.md'] ← 부분 파일 잔존
wrapper called: 0
```
같은 부류를 PR 은 `lib.sh:2042`(`|| { rm -f "$f"; return 1; }`)과 `run_loop.sh:201-205` 에서 고쳤고 S9 로 고정했지만, 러너에는 대응이 없고 테스트(S4/S4b)도 `subprocess.run` 의 OSError 만 다룬다. 브리프 항목 1 의 "쓰기 실패 시 부분 파일이 남지 않는지"에 대해 러너는 **아니오**다.
→ 권고: `with` 블록을 `try` 안으로 옮기고 `except OSError` 에서 `unlink` 후 `CompletedProcess(cmd, 126, "", "cannot write the prompt file: …")` 를 돌려주면 exec 실패와 같은 "실패 제출" 경로로 합쳐진다(3–4줄). `self.mam_dir.mkdir` 실패도 같은 블록에서 잡힌다.
## 2. 실패 처리 (항목 2)
- **exec 실패(OSError → rc 126)**: `_run_cmd` 가 `CompletedProcess(cmd, 126, "", "cannot start the wrapper: … (prompt N bytes, wrapper …)")` 를 돌려주므로 첫 시도·busy 재시도 모두 같은 "실패 제출" 경로로 들어간다. 재시도 실패 시 job id 가 없는 경우에도 이제 `log_error` 를 남긴다(`runner.py:525`, 이전에는 무언). `failed_submit_jobs` 처리는 바뀌지 않았다(id 가 있으면 등록, 없으면 `None` 반환 → 호출자가 `return 1`). S4/S4b 가 E2BIG·EAGAIN 으로 고정.
- **재심·중재 미등록 → rc 1**: `:1354-1357`, `:1376-1378`. 판정 잡이 등록조차 안 됐는데 "반론 기각"이나 "중재 없음"으로 흘러가던 것을 실패로 바꾼 것이라 맞다.
- **순응(compliance) 잡 `wait_for_job` 실패 → rc 1** (`:1392-1395`): 의도된 강화로 판단한다. 근거는 **정규 수정 잡과의 대칭**이다 — 일반 correction 잡도 `if not correct_jid or not self.wait_for_job(correct_jid): return 1` (`:1323-1326`) 로 끝난다. 순응 잡은 "반론이 기각됐으니 그대로 고쳐라"는 수정 잡의 변형이므로, 시간 초과·에러로 닫힌 잡을 두고 다음 리뷰 라운드로 가면 **바뀌지 않은 트리를 다시 리뷰**하게 된다. 루프가 `wait_for_job` 실패를 통과시키는 자리는 이제 self-review(`:1187`)뿐이고 그것은 판정 리포트로 따로 걸러진다. S3c 가 세 경로(comply/adjudication/arbitration)의 미등록을 고정한다.
## 3. `MAM_DIFF_MAX_BYTES`/`MAM_DIFF_MAX_LINES` (항목 3)
- `diff_collect.sh:9-11` 주석이 "증거 크기의 상한이지 전달 상한이 아니다"를 명시하고, 상한값·잘림 마커·`runner.py` 의 l15 규칙(잘린 diff 면 diff-only 원격 리뷰어 거부, 로컬 진행)은 **변경되지 않았다**(runner 의 해당 블록은 diff 밖). S5 가 "옛 사각지대"(131,072 < diff < 200,000 바이트)의 diff 가 통째로 전달됨을 고정한다.
- **H2 (비차단, 문서)**: loop SKILL.md 에는 이 두 변수도, 잘림 마커의 의미도 적혀 있지 않다(grep 0건). delegate-job SKILL.md 는 argv 상한만 말한다. "전달은 무제한, 증거는 200 KB/4,000 줄에서 잘리며 잘린 diff 는 diff-only 원격 리뷰어에게 증거가 못 된다"는 한 문단이 loop SKILL.md 에 있어야 항목 3 의 "일관 반영"이 완성된다.
## 4. `create_session.sh` (항목 4)
- 잡 등록은 종전대로 lib.sh 의 `delegate_submit_job`(파일 경유)이고, 그 뒤 `<jobs>/<id>/brief.md` 에 과제를 쓰고 pane 에는 `Task: read the complete task in <절대경로> and follow it.` 만 주입한다(`:625-646`). `$pub`(`delegate_publish_cmd` 의 `--expect-dispatch-token`·`--attempt-id`)와 `started` 기반 판정은 **손대지 않았다** — #23 계약 유지. S8 이 주입문에 brief 경로는 있고 과제 본문은 없음, `--expect-dispatch-token`·`--event started` 가 남아 있음을 단언한다.
- 경로: `${MAM_JOBS_DIR:-${WORKSPACE_ROOT:-$PWD}/.mam/jobs}` 를 절대화하므로 워크트리 creator(제어 루트 바인딩)도 자기 cwd 와 무관하게 읽을 수 있다. 래퍼가 만드는 `brief.md` 와 같은 자리다.
- 실패 시 잡 `error` + `JOB_TERMINALIZED=1` + exit 1 → 기존 트랩이 세션을 되돌린다(초기화 실패와 같은 취급).
## 5. 새 테스트 22건 (항목 5)
- **실제 경로를 지나는가**: S1 은 실제 래퍼(`bash multi-agent-mux-delegate-job submit --dry-run`)와 실제 `registry.py` 로 140,000 바이트를 등록해 **바이트 단위 동일**을 단언한다. S2/S3/S3b/S3c 는 `python -m lib_py.loop` 를 하위 프로세스로 띄우고 가짜 래퍼가 받은 인자를 검사한다. S7/S7b/S9 는 `run_loop.sh`(MAM_LOOP_PY=0)를 실제로 돈다. S8 은 mock herdr 위에서 `create_session.sh` 를 실제로 돈다. mock 으로 우회하는 것은 S4/S4b(`subprocess.run` 을 OSError 로 대체 — 커널 대신 코드 경로를 검증하는 것이 목적)뿐이다.
- **macOS**: `MAX_ARG_STRLEN` 이 없어 단일 인자 150 KB 가 통과하므로 S1b(음성 대조군)는 `linux_only` 로 **skip** 되고, S8 의 `strace` 검사는 Linux 에서만 붙는다. 즉 이 호스트에서 스위트는 **커널 상한이 아니라 코드 경로**(파일로 전달됐는가)를 검증한다 — 브리프의 우려 그대로이며, 설계상 옳다. 실제 상한 재현은 Linux CI 몫.
- **격리**: `_env()` 가 `HOME`·`WORKSPACE_ROOT`·`DELEGATE_JOB_LOGS_DIR` 를 `tmp_path` 로, 브로커를 `127.0.0.1:9` 로 고정하고 `MAM_JOBS_DIR`/`MAM_REAL_ROOT` 를 제거한다. 실제 `.mam`·홈 접촉 없음(§6 실측).
- **수정 없이는 실패하는가 — 확인함**: 검증 경로 사본에서 PR 이 바꾼 소스 8파일을 `1debc7d` 판으로 되돌리고 새 파일을 돌린 결과 **18 failed / 3 passed / 1 skipped(S1b)**. 통과 3건은 정정 전에도 성립하던 것(래퍼 실패 → rc 1 등)이다.
## 6. 검증 경로에서의 테스트 실행 (항목 5·6)
```
tests/test_issue36_prompt_transport.py tests/test_issue5_phase1_delegation.py tests/test_issue14_delivery_ack.py
tests/test_loop_cli.py tests/test_issue21_loop_b.py tests/test_issue21_acceptance.py tests/test_issue23_24_startup_path.py
tests/test_issue25_plan_reuse.py
→ 1 failed, 237 passed, 1 skipped in 422.71s (0:07:02)
```
- **skip 1**: S1b(`linux_only`) — 설계된 것(§5).
- **failed 1: S9 — 코드 결함이 아니라 테스트의 macOS 이식성 문제(H5).** S9 는 `ulimit -f 1` 로 쓰기를 실제로 실패시킨 뒤 세 가지를 단언한다: rc≠0, stderr 에 `File too large`, 부분 파일 없음 + 원인 문구. 같은 하네스를 scratchpad 에서 직접 돌린 결과 **rc=1, 부분 파일 없음, `delegate_job_safe: cannot write the prompt file` 출력** — 즉 PR 이 고친 동작(제거 + 원인 표시)은 macOS 에서도 성립한다. 실패한 것은 두 번째 단언뿐이다: macOS 의 `/bin/bash` 3.2 는 `printf` 의 쓰기 실패에 대해 `printf: write error: File too large` 문자열을 내지 않는다(Linux bash 5 의 형식). `strerror(EFBIG)` 자체는 macOS 에서도 "File too large" 이므로 순수하게 bash 버전 차이다. S1b 처럼 `linux_only` 로 두거나, 그 단언을 빼고 원인 문구 단언만 남기면 된다.
- 기존 계약 스위트(issue5 phase1 의 registry CLI 스냅샷·help 캡처, issue14, loop_cli, issue21 loop B·acceptance, issue23/24 S7*, issue25)는 **전부 통과** — `--prompt` 하위 호환과 온보딩 계약이 유지된다(항목 4·6).
### 격리·위생 실측
| 항목 | 결과 |
|---|---|
| 실제 `.mam/jobs` / `.mam/delegate_job_logs` 전후 | **차이 0 / 차이 0** |
| 실제 `.mam` 의 `prompt-*` 잔존 | **0** |
| 검증 경로 `.mam` 의 `prompt-*` 잔존 | **0** (러너·셸 러너·lib.sh 의 정리가 실제 실행에서도 성립) |
| 검증 경로 tracked 파일 | 변화 없음(HEAD `59be1c8`), 본 체크아웃 `main 1debc7d` |
| 실행 시간 | 새 파일 22건은 34 s 안팎(되돌린 사본 기준 34.5 s); 전체 7 분은 issue14/18 계열의 벽시계 시험 몫 |
## 7. 문서 (항목 7)
- delegate-job SKILL.md `:44-48`: `--prompt-file` 의 의미와 argv 상한, "루프는 항상 파일을 쓴다" 명시 ✓. 래퍼 usage(`:112-121`)도 갱신 ✓.
- loop SKILL.md `:267`: 파이프라인에서의 종료 코드(`pipefail`/`PIPESTATUS`) 안내 ✓. 그러나 `MAM_DIFF_MAX_*` 의 새 의미는 없다(H2).
- `.claude/skills`: 본 체크아웃의 것은 git 에 추적되지 않는 로컬 심링크(`../../.agents/skills/*`)라 자동 동기화된다 — 배포 사본 정합 문제 없음.
## 8. 병합 차단 여부 (항목 8)
버전 락스텝 bump 부재(`lib.sh:32` `4.7.2`, 오케스트레이터가 4.7.3 으로 처리 예정) 외에 **병합을 막을 결함은 없다**. 다만 H1 은 릴리스 커밋에 함께 넣거나 즉시 후속으로 닫기를 권한다 — 셸 쪽과 같은 부류이고 수정이 세 줄이다.
| # | 지적 | 성격 | 근거 |
|---|---|---|---|
| H1 | 러너의 프롬프트 파일 **쓰기** 실패 시 부분 파일 잔존 + 예외 전파(126 실패 제출로 합류하지 않음) — 재현 | 비차단(경계 상황), **후속 권고 1순위** | `runner.py:472-477` vs `:490-502`; §1 H1 |
| H2 | `MAM_DIFF_MAX_*` 의 "증거 상한, 전달 무제한" 의미가 loop SKILL.md 에 없음 | 문서 | `diff_collect.sh:9-11` 에만 있음 |
| H3 | 래퍼는 **빈** `--prompt-file` 을 받아들여 빈 프롬프트를 등록한다(실측) | 사소 | `multi-agent-mux-delegate-job:195` 의 `-n "$PROMPT" \|\| -n "$PROMPT_FILE"` |
| H4 | 래퍼의 `$PROMPT="$(<file)"` 는 후행 개행을 잃는다 — registry 의 프롬프트(파일 그대로)와 브리프 본문이 그 점에서 다를 수 있음 | 사소 | `:193` |
| H5 | S9 의 `File too large` 단언이 macOS bash 3.2 에서 실패(문자열 형식 차이); 실질 단언 2개는 통과 | 테스트 이식성 | §6 |
## 9. 교차검증 가이드
```bash
RP=/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20260924T080014Z-2b3df0/1
PY=/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.venv/bin/python
git -C "$RP" rev-parse HEAD 'HEAD^{tree}' # 59be1c8… / a009c90…
grep -rn -- '--prompt ' "$RP/.agents/skills" | grep -v '\.md:' # 래퍼 usage/기본값, cli 주석, run_loop.sh 의 함수 호출뿐
# H1 재현: 러너 in-process, NamedTemporaryFile.write 가 OSError(28) → .mam/prompt-* 잔존 + 예외 전파 (§1 H1 의 스크립트)
# S9 하네스 직접 실행(macOS): rc=1, 부분 파일 없음, "cannot write the prompt file" 출력, 단 "File too large" 문자열 없음
# 되돌린 사본: 소스 8파일을 1debc7d 판으로 → tests/test_issue36_prompt_transport.py → 18 failed / 3 passed / 1 skipped
```
[VERDICT: PASS]
@@ -0,0 +1,14 @@
{
"loop_id": "loop-20261020T133532Z-a1684a",
"base_sha": "ff8b381520ae04e80c73a9e18bfad73d47e853c9",
"head_sha": "feccfd5e0356c8acf0438bc68bce7ea36ce2588e",
"tree_sha": "0f0f33cc22f5e492a0ddfccb6bac5be888c10d1a",
"review_commit": "feccfd5e0356c8acf0438bc68bce7ea36ce2588e",
"reviewer": "planner-reviewer-claude-01",
"job_id": "5bdb3a81",
"verdict": "PASS",
"transport": "herdr",
"evidence": "review-tree",
"isolation": "worktree",
"verified_at": "2026-10-20T15:51:16Z"
}
@@ -0,0 +1,116 @@
# Issue #40 리뷰 — Class A 세션 ID 자동 확정, agy 다른 작업 공간 ID 오등록 방지 (job 5bdb3a81)
- 리뷰어: planner-reviewer-claude-01
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261020T133532Z-a1684a/1`
- identity: HEAD `feccfd5e0356c8acf0438bc68bce7ea36ce2588e`, tree `0f0f33cc22f5e492a0ddfccb6bac5be888c10d1a` 가 일치한다. 작업 트리는 clean 이고 `.mam` 은 없다.
- 범위: base `ff8b381`..head 1커밋, 19개 파일. 판정은 base..head 전체에 대한 것이다.
- 기준 문서: 이슈 본문과 댓글 전체(`issue40.txt`)를 끝까지 읽고 합의 사항과 대조했다.
- 환경: macOS, Python 3.9.6.
- 실제 에이전트 홈은 `ls` 와 `lsof -p <agy pid>`(프로세스 테이블 조회)로만 봤다. 파일 자체를 연 적은 없다.
- 재현과 테스트는 모두 scratchpad 의 임시 HOME, mock herdr, git archive 사본에서 했다.
## 결론
합의된 설계(PR #41 리뷰·이슈 댓글)대로 구현됐고, 완료 조건 1-4 가 테스트로 고정됐다. 각 조건의 테스트는 base 에서 실패하고 head 에서 통과한다. 이 변경이 만든 회귀는 찾지 못했다. 남은 것은 테스트 보강 권고와 운영 위험 기록이다.
## 설계 확인 (합의 대비)
**어댑터는 사실과 판정만 제공한다.**
- `id_lifecycle`: 기본값은 `POST_DISCOVERED`. claude·grok 만 `PRE_ASSIGNED` 이다(`base.py:378-381`, `claude.py:188`, `grok.py:18`).
- `capture_active_session_id`(`base.py:383-401`): `discover()` 후보 가운데 `verify_session_uuid` 를 통과하고 다른 행이 기록하지 않은 것이 정확히 하나일 때만 `captured` 를 돌려준다. 그 밖에는 이유가 담긴 결과(`capture_result`)를 돌려준다. 읽기 전용이다.
- 설정 추가나 리팩터링은 없다.
**agy 경로 (`agy.py`)**
- `verify_artifact` discover 모드(`:97-106`): `last_conversations.json` 이 그 id 를 다른 cwd 에 매핑하면 거절한다(workspace_key 로 비교). 다른 행이 기록한 id 는 캐시가 일치해도 항상 거절한다. 이제 캐시 일치가 이 검사를 건너뛰지 못한다.
- `capture_active_session_id`(`:129-152`): 후보는 `last_conversations.json[cwd]` 하나뿐이다. 그 대화의 presence lock 이나 DB 를 그 행의 pane 프로세스 또는 그 자손이 열고 있어야 확정한다(`_owner_check`, `:154-183`, lsof/ps). 증명하지 못하면 확정하지 않는다.
- 이 근거를 실제로 확인했다. 이 Mac 에서 실행 중인 agy 3개(`--conversation <id>`)는 모두 자기 `presence/<id>.lock` 을 열고 있다(`lsof -p`).
- `_holder_pids`·`_process_parents` 를 macOS 에서 직접 확인했다. 아무도 안 연 파일은 `[]`, 연 파일은 보유 pid 를 돌려준다(0.13s). ps 표에는 자기 pid 가 들어 있다.
**reconcile drift C (agy·hermes·opencode·codex) 가 하나로 합쳐졌다** (`reconcile.sh:778-811`).
- 네 에이전트가 모두 `autopin.capture` 를 거쳐 같은 어댑터 판정을 쓴다. agy 의 `*.db` 전체 glob 은 사라졌다.
- 기존 안전장치는 그대로다: 후보가 둘 이상이면 C-ambiguous, 뷰포트가 다르면(rc 1) C-warn 으로 pin 안 함, 뷰포트 확인이 불가하면(그 밖의 rc) degraded pin.
- 다른 행이 가진 id 배제는 더 엄격해졌다. 예전에는 같은 cwd 의, 멈추지 않은, 같은 에이전트 행만 봤다. 지금은 다른 모든 행의 모든 own key 를 본다(`autopin.claimed_elsewhere`).
- 지운 hermes·opencode·codex SQL 은 각 어댑터의 `discover()` 와 같은 범위(cwd·epoch·`parent_id IS NULL`)다. codex 는 `CODEX_HOME` 을 따른다(`codex.py:65`).
**수신 측 처리기 (`lib_py/agents/autopin.py`)**
- 호출 지점:
- 래퍼: `finalize_unfinished` 뒤 `completed` 일 때(`multi-agent-mux-delegate-job:454-458`). stderr 로만 출력하므로 마지막 stdout 줄(audit-log 경로) 계약은 그대로다.
- `create --onboard` 의 completed 분기(`create_session.sh:754-756`)
- 루프 `wait_for_job` 직후의 서브프로세스(`runner.py:679, 690-705`, G9 경계를 지킴)
- 공통 헬퍼 `delegate_autopin_completed`(`lib.sh:2470-2480`)는 `|| true` 라 잡 결과와 종료 코드를 바꾸지 않는다. `set -u` 에서 쓰는 변수(`AGENT_SESSIONS_YAML`, `_disp_tok`, `DELEGATE_JOB_TOKEN`, `task_reg_dir`)는 모두 정의돼 있다.
- 중복 방지:
- 키는 `(job_id, dispatch_token, attempt_id)` 이다. 잡 디렉터리의 `autopin.lock`(flock)과 `autopin.json` 마커를 쓴다. 마커는 원자 교체로 쓴다.
- 쓰기는 `atomic_dump_yaml` 의 mutation 으로 한다. 이 모듈의 `_MUTATION` 을 `sys.modules[__name__]` 로 찾아 `python -m` 에서도 같은 객체를 쓴다. 쓸 것이 없으면 `SystemExit(0)` 으로 쓰지 않고 끝나며, `atomic_yaml.py:195` 의 finally 가 정리한다.
- 건너뛰는 잡: remote, human, completed 가 아닌 잡, 오래된 token. pre-assigned 와 이미 id 가 있는 행은 쓰기 없이 끝난다.
- 메타데이터: pin 은 `session_id_source=discovered`, `session_id_verified=true` 를 함께 기록한다. reconcile 의 `_pin_and_verify_resume` 도 같은 `pin_row` 를 쓴다. 이슈 댓글이 짚은 불일치(선택 항목)가 해결됐다.
- publish_event.py 는 바뀌지 않았다. 에이전트는 제어 평면에 쓰지 않는다(`test_call_sites_and_publisher_boundary`).
- 잠금 안에서 뷰포트 확인과 resume `--dry-run` 을 서브프로세스로 실행한다. 예전 reconcile 도 mutation 안에서 똑같이 했다. `load_state_json` 은 YAML flock 없이 SQLite 를 읽으므로 교착은 없다.
## 완료 조건별 테스트 (base 실패 / head 통과)
`tests/test_issue40_auto_pin.py`(27개)를 base 사본에 넣어 실행하면 **16 failed, 13 errors**(errors 는 base 에 없는 `agy._holder_pids` 를 패치하는 fixture 때문), **41 passed**(issue64 포함) 이다. head 에서는 issue40+issue64 **70 passed**.
| 조건 | 테스트 | base 결과 |
|---|---|---|
| 1 | `test_agy_verify_rejects_id_cached_for_another_workspace` | `True is False` |
| 1 | `test_reconcile_does_not_pin_another_workspaces_agy_conversation` | `'b4000002…' is None` — 실제로 B 의 id 를 A 행에 기록함 |
| 1 | `test_autopin_does_not_pin_…` | — |
| 1 | issue64 `:407` 단언 | `is False` 로 바뀜 |
| 2 | `test_completed_onboarding_pins_agy_without_daemon`(MAM_NO_SUBSCRIBER_DAEMON=1, B 가 더 늦게 갱신) | — |
| 2 | `test_completed_first_job_pins_opencode_codex_hermes`[3] | 실패 |
| 2 | `test_reconcile_pins_agy_owned_by_the_pane`, `test_lib_sh_helper_pins_through_the_cli`, `test_create_onboard_pins_without_daemon` | 실패 |
| 3 | `test_wrapper_and_loop_recovery_pin_once`(capture 1회, YAML mtime 불변), `test_loop_recovery_pins_when_the_wrapper_did_not` | 실패 |
| 3 | `test_ambiguous_candidates_leave_reason_without_pin`(행에 `autopin ambiguous (job amb): 2 candidates`) | 실패 |
| 4 | `test_preassigned_and_existing_ids_are_untouched`, `test_ineligible_jobs_pin_nothing`, `test_stale_dispatch_token_pins_nothing` | — |
- reconcile 테스트는 세션을 mock herdr state 에 running 으로 등록한다(`_mock_agent`). 그래서 drift A 에서 멈추지 않고 실제로 drift C 까지 간다. base 에서 B 의 id 가 기록되는 것이 그 증거다.
뮤테이션: git archive 사본에서 issue40 모듈만 돌렸다.
| 뮤테이션 | 결과 |
|---|---|
| 다른 cwd 거절 제거 | 1 failed |
| 중복 마커 무시 | 2 failed |
| 모호 판정 제거 | 1 failed |
| agy 소유 확인 제거 | 4 failed(같은 cwd 형제 교차 pin 포함) |
| 이미 id 가 있는 행 보호 제거 | 1 failed |
| remote·human 제외 제거 | 2 failed |
| 래퍼 호출 제거 | 1 failed(정적 검사만 잡음) |
| **pre-assigned 보호 제거** | **실패 0** (아래 T1) |
## 관련 테스트 묶음
- 조건: `env -i`, 임시 HOME, `XDG_DATA_HOME=<scratchpad>`, basetemp 는 scratchpad.
- 대상 18개 모듈: issue40, issue64, a4, issue10_13, issue31, issue50_dispatch, issue55, issue57, issue60_linux_fixes, issue60_opencode_v2, tier1, tier2, uuid_target, orc_onboard, issue21_phase2_loop_a, issue5_phase3_loop, issue21_worktree_prereqs, issue17_workspace_root_isolation.
- 결과: **577 passed, 1 skipped, 3 failed**, 18분.
- 실패 3개(`test_issue21_phase2_loop_a::test_w10_…[install.sh|install_mam.sh]`, `test_w11_…`)는 환경 탓이다.
- 설치 스크립트가 시스템 python3 의 PyYAML 을 찾는데, 이 Mac 의 PyYAML 은 사용자 site(`~/Library/Python/3.9/...`)에만 있다. 내가 HOME 을 임시로 바꿔서 보이지 않았다.
- base 사본에서도 같은 3개가 같은 이유로 실패하고, 설치 스크립트는 이 diff 에 없다. 판정 근거로 쓰지 않았다.
- skip 1개: PATH 에 opencode 가 없는 실바이너리 계약 테스트.
- 부작용 확인:
- 실제 `~/.gemini/antigravity-cli/conversations`·`~/.hermes`·`~/.local/share/opencode` 목록은 전후가 같았다.
- scratchpad XDG 디렉터리는 실행 뒤에도 비어 있었다.
- #55 가드 오탐은 없었다.
- 린트:
- `git diff --check` 는 깨끗하다.
- 바뀐 셸 4개(lib.sh, create_session.sh, 래퍼, reconcile.sh)가 `/bin/bash -n`(3.2)을 통과한다.
- 바뀐 Python 은 3.9 로 파싱된다. `/usr/bin/python3` 로 `lib_py.agents.autopin`·`lib_py.loop.runner` 를 import 할 수 있다.
## 비차단
- **T1. pre-assigned 보호(`autopin.capture` 의 `id_lifecycle != POST_DISCOVERED`)는 테스트로 고정되지 않았다.**
- 테스트의 claude·grok 행은 이미 id 가 있어서 `already-set` 에서 먼저 끝난다.
- id 가 비어 있는 claude 행이 생기면(예: 행을 손으로 고침), 이 보호 없이는 기본 capture 가 claude `discover()` 로 남의 transcript 를 pin 할 수 있다.
- 권고: `claude_session_id_own: None` 인 claude 행에서 `pre-assigned` 가 나오는지 확인하는 한 줄을 추가한다.
- **T2.** 래퍼 진입점은 정적 검사(`test_call_sites_…`)로만 고정됐다. 헬퍼(`lib.sh`)와 CLI, create, 루프는 실행으로 검증됐다.
- **R1. agy 의 pin 은 이제 세 가지에 기댄다**(reconcile drift C 도 같다):
- (a) lsof·ps 가 있어야 한다
- (b) 행에 pane pid 가 있어야 한다
- (c) agy 가 presence lock 을 열고 있어야 한다
- (c) 는 resume 된 agy 3개에서 확인했다. 새로 띄운 agy 의 첫 턴 직후는 실제 agy 를 띄울 수 없어 확인하지 못했다.
- lsof 가 없는 Linux 에서는 agy 가 영영 pin 되지 않는다. 예전 glob 경로는 pin 했다(오등록 위험을 감수하고). autopin 경로는 행에 `autopin unverifiable-owner … lsof is unavailable` 을 남기지만, reconcile 은 아무것도 남기지 않는다.
- 설계상 의도된 안전 쪽 선택이다. 그래도 Linux 의존성으로 문서에 한 줄 적어 두기를 권한다.
- **R2.** 중복 마커는 실패 결과도 기록한다. 래퍼 시점에 `no-candidate` 였던 잡은 루프 복구 경로에서 다시 시도되지 않는다. 다음 잡의 completed 나 reconcile 이 다시 시도한다. 합의("job/token/attempt 단위로 중복 금지")와 맞다.
- **범위 밖(보고만).** stop 의 캡처 경로는 정리하지 않았다(이슈가 "크면 하지 말고 보고만"이라고 함).
[VERDICT: PASS]
@@ -0,0 +1,14 @@
{
"loop_id": "loop-20261008T234013Z-4f5d21",
"base_sha": "97c2946ee0e11eabcecafe6407755c33c0289218",
"head_sha": "a93c9bf29279f65ce0b9c9fb2c1aeea90a659420",
"tree_sha": "741c19ba4c17601768b06684d5843d218a72beb1",
"review_commit": "a93c9bf29279f65ce0b9c9fb2c1aeea90a659420",
"reviewer": "planner-reviewer-claude-01",
"job_id": "616578e7",
"verdict": "PASS",
"transport": "herdr",
"evidence": "review-tree",
"isolation": "worktree",
"verified_at": "2026-10-09T00:45:03Z"
}
@@ -0,0 +1,47 @@
# 리뷰 리포트 — Job 616578e7 (loop-lock: 테스트가 체크아웃의 루프 잠금을 잡지 않게, 97c2946..a93c9bf)
- 리뷰어: planner-reviewer-claude-01 (claude)
- 리뷰 identity: loop-20261008T234013Z-4f5d21 / base 97c2946 / head a93c9bf / tree 741c19b — 검증 경로에서 `git rev-parse HEAD 'HEAD^{tree}'` 일치, 미커밋 변경 없음
- 범위: 커밋 1개(+55줄), 테스트 3개 파일: test_o3_scoped_guard.py 의 b13 두 곳에 `MAM_LOOP_MARKER=tmp_path/...`, 새 tests/test_loop_marker_isolation.py(L1), test_issue17_workspace_root_isolation.py W1 에 `.mam` 부재 단언 1줄. 제품 코드(.agents/skills)·버전·VERSIONS 변경 없음(브리프대로).
## 결론 요약
할 일 1~4 가 요구대로이고, 내 독립 조사(grep + 사본 트리 실행)로도 누락된 실행 지점이 없다. 잠금 동작을 시험하는 테스트들의 marker 경로는 바뀌지 않았다. 차단 결함 없음.
## 1. 조사(1) — 직접 grep 한 결과 (run_loop.sh 실행 지점 전수)
run_loop.sh 는 :15 `MAM_REAL_ROOT=${MAM_REAL_ROOT:-$REPO_ROOT}`, :110 `--help` 와 :66-141 인자 오류는 :181 의 marker 결정·:248 `mam_acquire_loop_lock` **이전**에 종료, loop_lock.sh:66 이 부모 디렉터리를 mkdir -p. Python `LoopRunner` 는 잠금을 잡지 않는다(runner.py:1224-1226 은 release 뿐, acquire 는 run_loop.sh:248 에만 있음).
| 실행 지점 | 분류 | 근거 |
|---|---|---|
| test_o3_scoped_guard.py:388, :472 (b13 두 테스트) | **대상 → 수정됨** | 체크아웃 run_loop.sh, `--creator nonexistent-agent --task …`, cwd=REPO_ROOT; 이제 `MAM_LOOP_MARKER=tmp_path/loop-guard-active`(:390, :470) |
| test_issue5_phase3_loop.py:166, :184 | 이미 지정 | :163 `MAM_LOOP_MARKER=tmp_path/loop-marker` |
| test_loop_cli.py:52 (`_run_loop`) | 이미 지정 | env_extra 에 `MAM_LOOP_MARKER=tmp_path/loop-guard` |
| test_o2_race_free_lock.py:203 (o2_12) | 이미 지정(잠금 경합 자체를 시험) | :202 `MAM_LOOP_MARKER=marker(tmp_path)` — 의도 유지 |
| test_issue25_plan_reuse.py:367 (p7b) | 이미 지정 | `MAM_LOOP_MARKER=tmp_path/loop.marker` |
| test_issue21_loop_b.py:239, test_issue21_worktree_prereqs.py:405 | 이미 지정 | WORKSPACE_ROOT=tmp + `MAM_LOOP_MARKER` |
| test_issue21_acceptance.py:298, test_issue36_prompt_transport.py:540, test_b7_diff_untracked.py:189, test_tier4_e2e.py:285 | 복사본 트리의 run_loop.sh(`main`/`ctl`/`base`/`tmp_path`) | 체크아웃이 아님(acceptance 는 marker 도 지정) |
| test_issue5_phase4_runtime.py:132-146 (p4_03), test_issue25:355, test_issue5_phase3:151, test_o1_rebuttal.py:72 | `--help` → :110 에서 종료 | 잠금 전 |
| test_o1_rebuttal.py:76 (`--max-rebut abc`) | 인자 오류 → 잠금 전 종료 | :66-98 |
| test_o3 Z-7/Z-14(:137, :155), test_deploy_freshness:473, test_issue5_phase0_contracts:481, test_o1:24/:166 | 소스 텍스트 읽기만 | 실행 없음 |
| LoopRunner in-process(issue18, issue21_loop_b, issue25, issue30, issue33, issue34, issue36, issue6, phase4 등) | 잠금 미획득 | 위 runner.py 근거; 해당 없음 |
경험적 확인(W1 방식): HEAD 트리 사본에서 `WORKSPACE_ROOT=<사본>` 으로 브리프 후보 15개 모듈을 **파일 전체** 단위로 실행하고 매번 `.mam` 확인 — **15개 모두 clean**(b7 20, deploy_freshness 13+18 skip, issue21_acceptance 10, issue21_loop_b 34, worktree_prereqs 35, issue25 17, issue36 23+1 skip, phase0 41, phase3 12, phase4 5, loop_cli 9, o1 10, o2 22, o3 32, tier4_e2e 5 — 모두 passed). 놓친 실행 지점 없음.
## 2. 할 일 2·3 — 수정 방식과 회귀 테스트
- 대상이 한 모듈의 두 테스트뿐이라 conftest fixture 대신 각 호출에 env 지정 — 브리프의 "검토" 요건에 맞는 최소 변경. o2 의 잠금 경합·o3 의 marker 검사(mam_sandbox 아래 marker)는 손대지 않았다.
- L1(test_loop_marker_isolation.py:36-50)은 W1 과 같은 방법(체크아웃 복사 → 7개 노드 실행 → `.mam` 부재)이며 NODES 는 위 표의 "잠금까지 가는" 7개와 정확히 일치한다. W1 에도 `.mam` 디렉터리 자체의 부재 단언이 추가돼(:71) 잠금 디렉터리 생성이 두 테스트에서 잡힌다.
- **수정 전 코드에서 실패 확인**: L1 자체는 `git ls-files` 가 필요해 `git archive` 사본에서는 못 돌리므로, L1 의 방법을 base 트리 사본 안에서 직접 재현했다 — 7개 노드를 `WORKSPACE_ROOT=<base 사본>` 으로 실행(7 passed) → 사본에 **`.mam/` 생성**(L1 의 마지막 단언이 실패하는 조건). 노드별로는 **b13 두 테스트만** 생성하고 나머지 5개는 clean — 테스트 머리말의 "The first two took the checkout's lock at 97c2946" 와 일치. HEAD 에서는 (a)(b) 모두에서 L1·W1 통과.
## 3. 할 일 4 — 실행 (검증 경로 워크트리, 워크트리 .venv Python 3.9.6, `env -i` 로 HERDR_*·MAM_*·MQTT_* 제거)
| 실행 | 결과 |
|---|---|
| (a) WORKSPACE_ROOT 없이: test_issue17_workspace_root_isolation, test_loop_marker_isolation, test_o3_scoped_guard, test_o2_race_free_lock | **57 passed / 0 failed** (45 s); 실행 뒤 워크트리에 `.mam/` **없음** |
| (b) WORKSPACE_ROOT=<워크트리 밖 센티널>: 같은 4개 파일 | **57 passed / 0 failed** (45 s); 센티널 **비어 있음**; 워크트리에 `.mam/` 없음 |
- 본 체크아웃의 실제 잠금 `.mam/loop-guard-active` mtime 전후 동일(1791502813) — 이 리뷰 중 본 체크아웃에서는 아무것도 실행하지 않았다. 실제 .mam/jobs 항목 수 불변. 브로커 bind 제약 없음.
- 이식성: Python 테스트 코드만(3.9 문법). L1 은 nested pytest + `git ls-files`, 이 Mac 에서 통과.
## 4. 비차단
- L1·W1 둘 다 노드 목록을 수동 유지한다(이전 리뷰의 같은 지적). run_loop.sh 를 잠금까지 실행하는 테스트를 새로 추가하면 L1 NODES 에도 넣어야 한다.
- L1 은 `_copy_checkout` 을 W1 모듈에서 import 하므로 두 테스트 파일이 결합돼 있다 — 작동에는 문제없고, 공용 헬퍼를 conftest 로 옮길지는 취향 문제.
- 새 관찰 없음.
[VERDICT: PASS]
@@ -0,0 +1,14 @@
{
"loop_id": "loop-20261013T001819Z-131fa6",
"base_sha": "8b0b4a07dc76a4915895bdde6170dc8eed9b5f74",
"head_sha": "325b056057fcdd717d2c81017fc5ef57183cb4d0",
"tree_sha": "7905896f005159d596e3872d0ee2c3c12061f09a",
"review_commit": "325b056057fcdd717d2c81017fc5ef57183cb4d0",
"reviewer": "planner-reviewer-claude-01",
"job_id": "63919c5d",
"verdict": "PASS",
"transport": "herdr",
"evidence": "review-tree",
"isolation": "worktree",
"verified_at": "2026-10-13T00:57:46Z"
}
@@ -0,0 +1,59 @@
# 테스트의 실제 herdr 서버 누출 수정 리뷰 (job 63919c5d)
- 리뷰어: planner-reviewer-claude-01 (Reviewer)
- 리뷰 identity: loop-20261013T001819Z-131fa6 / base 8b0b4a07 .. head 325b0560 (커밋 2개: 525df2b 수정, 325b056 s11 기준 이동 / 4 파일) / tree 7905896f
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261013T001819Z-131fa6/1` — HEAD·tree 가 identity 와 일치, `git status --porcelain` 0 줄(시작·종료 시), `.mam` 디렉터리도 생기지 않음. 본 체크아웃 HEAD 8b0b4a0(main) 변동 없음.
- 실행 안전: 내 pane 은 `HERDR_SOCKET_PATH`·`HERDR_PANE_ID`·`HERDR_TAB_ID`·`HERDR_WORKSPACE_ID`·`HERDR_SESSION_NAME`·`HERDR_SESSION`·`HERDR_CONFIG_PATH`·`HERDR_BIN_PATH`·`HERDR_ENV` 를 물려받고 있어, 모든 테스트·탐침은 `env -i`(PATH/HOME/LANG/TMPDIR 만)로 돌려 브리프의 `-u` 목록을 포함한 전부를 비웠다. basetemp 는 세션 scratchpad 아래(경로에 "shim" 없음). 실제 바이너리를 부른 것은 일회용 HOME 에서의 `status server`(읽기 전용, 서버 미기동)뿐이다. Python 은 본 체크아웃 `.venv`(3.9.6, paho 2.1.0). `.mam.env` 는 읽지 않았다.
## 1. 원인 판정이 맞는가
**원인 1 (조상 `*-shim` 이 mock 을 가림).** base 의 `_is_shim_path` 는 `*-shim/*` 를 `"/$1/"` 전체에 대해 매칭해 조상 디렉터리까지 걸렀고(`lib.sh` base), 생성 shim 의 `_resolve_real_herdr` 도 `[[ "$dir" != *"-shim"* ]]` 로 PATH 항목 문자열 어디든 걸렀다. basetemp `pytest-pr56-shim` 아래 mock `bin/` 은 둘 다에서 건너뛰어졌고 다음 후보인 `/opt/homebrew/bin/herdr` 가 "real" 이 된다. 수정 전 lib.sh 로 되돌린 scratch 복사본에서 L1 이 정확히 그 상태로 실패했다: `_resolve_real_herdr_path` 가 `…/pytest-x-shim/case0/bin/herdr` 대신 다음 PATH 항목의 디코이 `…/decoy/bin/herdr` 를 골랐다(`assert '/…/decoy/bin/herdr' == '/…/case0/bin/herdr'`). 판정 맞음.
**원인 2 (pane 의 `HERDR_SOCKET_PATH`).** 실제 herdr 0.9.3 바이너리를 일회용 HOME 에서 `status server` 로 읽기만 해 확인했다:
| 환경 | 바이너리가 가리킨 소켓 |
|---|---|
| `HERDR_SOCKET_PATH=/nonexistent/s.sock` + `--session t-zz` | `/nonexistent/s.sock` |
| `HERDR_SOCKET_PATH=/nonexistent/s.sock` + `HERDR_SESSION=t-zz` | `/nonexistent/s.sock` |
| `HERDR_SESSION=t-zz` 만 | `$HOME/.config/herdr/sessions/t-zz/herdr.sock` (가짜 HOME 아래) |
| `HERDR_SOCKET_PATH` 만 | `/nonexistent/s.sock` |
즉 `HERDR_SOCKET_PATH` 는 **`--session` 보다도** 우선한다. shim 이 항상 `--session <샌드박스 서버>` 를 붙여도 이 변수가 남아 있으면 실제 서버로 간다 — 원인 1 로 실제 바이너리가 선택되는 순간 샌드박스 HOME 은 아무 보호가 되지 못했다는 오케스트레이터의 실측과 일치한다. 두 원인은 독립적이고 둘 다 막아야 한다(1 만 고치면 pane 변수 누출 시 다른 경로의 실제 호출이 여전히 실제 서버로, 2 만 고치면 숨은 mock 대신 실제 바이너리가 가짜 HOME 에 서버를 띄움). 판정 맞음.
## 2. 수정 내용 검토
- `lib.sh:113-124 _is_shim_path`: 첫 `case` 에서 `*-shim/*` 만 빠지고 `.mam/shim`·`$_HERDR_SHIM_DIR_PATTERN`(`/multi-agent-herdr-shim/`)·`$_HERDR_SKILLS_BIN_PATTERN`(`/.agents/skills/.bin`) 은 그대로. `*-shim` 은 `"${1%/}"` 즉 인자 자신의 끝에만 적용. `lib.sh:130-136 _resolve_real_herdr_path` 는 PATH 항목(`_is_shim_path "$dir"`)과 후보 파일의 **canonical dirname** 둘 다 검사한다 — L1 의 mock 은 `tmp/bin/herdr` 로의 symlink 라 canonical dirname 은 `tmp/bin` 이고, 선택 결과는 PATH 항목 쪽 경로(`$dir/herdr`)다. 기존 래퍼 디렉터리 제외 여부: `.mam/shim` → 첫 case(부분 문자열, 조상 포함) 유지; `multi-agent-herdr-shim` → 패턴 상수 유지(`"/…/multi-agent-herdr-shim/…/"`); `.agents/skills/.bin` → 패턴 유지(이 저장소에는 그 디렉터리가 없다); 테스트의 `herdr-shim`(L1b)·`py-shim`·`ps-shim`(`test_issue17_item4_decisions.py:208`, `test_issue21_acceptance.py:283`, `test_issue30_stall_watch_stop.py:95`) 은 디렉터리 자신의 이름이 `*-shim` 이라 계속 제외.
- 생성 shim(`lib.sh:173-178`): `[[ "${dir%/}" != *-shim ]]`. `.mam/shim` 부분 문자열 검사는 그대로.
- `tests/conftest.py:293-298 mam_sandbox`: `HERDR_SOCKET_PATH`·`HERDR_PANE_ID`·`HERDR_TAB_ID` 제거. 기존의 `HERDR_SESSION_NAME`·`HERDR_SERVER_NAME`·`HERDR_WORKSPACE_ID`·`HERDR_CONFIG_PATH`·`XDG_CONFIG_HOME` 제거와 합쳐 pane 이 내보내는 라우팅 변수 중 실제 서버에 닿을 수 있는 것은 모두 빠진다(아래 비차단 메모의 `HERDR_SESSION` 참조).
- bash 3.2: `${1%/}`·`${dir%/}`·`case` 뿐, `bash -n` 통과. Python 3.9 컴파일 통과.
- s11 기준 커밋 525df2b: 생성 shim heredoc 이 바뀌었으므로 기준을 그 커밋으로 옮긴 것은 #45 때와 같은 방식이고, 검증 경로에서 s11 PASSED. 525df2b 는 이 브랜치의 첫 커밋으로 검증 경로에 존재한다.
## 3. 충분한가 — 다른 경로로 실제 서버에 닿는 테스트
- `mock_herdr`(`conftest.py:310`)·`mock_agents`(`:1412`) 는 `mam_sandbox` 에 의존하므로 mock 을 PATH 에 두는 테스트는 모두 샌드박스 환경(라우팅 변수 제거, HOME 교체)이다.
- `mam_sandbox` 없이 herdr 를 흉내 내는 파일은 `test_issue14_delivery_ack.py`·`test_issue18_liveness_contract.py` 둘(bash 함수 `_sks_herdr` 대체, HOME=tmp). 이 둘이 herdr **바이너리**를 한 번이라도 부르는지 보려고 호출을 기록하는 디코이 `herdr` 를 PATH 맨 앞에 두고 두 파일을 돌렸다: **43 passed, 디코이 호출 0 건**(로그 파일이 생기지 않음). 즉 이 둘은 라우팅 변수가 남아도 바이너리를 실행하지 않는다. (이 실행의 `1 error` 는 세션 teardown 의 #55 agent-home 가드가 `~/.gemini/antigravity-cli/conversations/f9bf3d55-….db-shm/.db-wal` 을 잡은 것으로, 브리프가 예고한 다른 워크스페이스의 실행 중 agy 대화 WAL/SHM 이다. 테스트 산출물이 아니므로 판정에 쓰지 않고 보고만 한다.)
- `test_issue6_fail_closed.py`·`test_issue6_turn_handshake.py` 는 `mock_herdr` 를 쓰므로 샌드박스다.
- 실제 바이너리가 아는 라우팅 변수는 `HERDR_SOCKET_PATH`(최우선)와 `HERDR_SESSION`(HOME 상대 경로)뿐임을 위 표로 확인했다. `HERDR_SESSION_NAME` 은 바이너리가 모르고 shim 만 `--session` 으로 바꾼다(어제 리뷰에서 확인). 따라서 `HERDR_SOCKET_PATH` 제거 + 가짜 HOME 이면 shim 경로든 직접 호출이든 실제 서버에는 닿지 않는다.
## 4. 테스트 결과
| 항목 | 결과 |
|---|---|
| head: `test_herdr_real_server_leak.py` + `test_issue23_24_startup_path.py` + `test_herdr_shim_contract.py` + `test_issue17_workspace_root_isolation.py` + `test_issue9_server_env.py` + `test_issue45_herdr_config.py` | **130 passed, 34 skipped**(skip 은 모두 `needs tomllib (Python 3.11+)` 류의 알려진 3.9 skip). L1·L1b·L2·s11 은 모두 PASSED 로 실행됨 |
| 수정 전 lib.sh(8b0b4a0) + 새 테스트 | **L1 실패**(디코이 선택), **L1b 통과**(자기 이름 `*-shim` 은 전후 모두 제외) |
| 수정 전 conftest(8b0b4a0) + 새 테스트 | **L2 실패**: `['HERDR_PANE_ID', 'HERDR_SOCKET_PATH', 'HERDR_TAB_ID']` 가 남음 |
| 디코이 sweep(`test_issue14`, `test_issue18`) | 43 passed, 디코이 호출 0 |
수정 전 재현에는 실제 herdr 가 실행될 수 있는 구성을 쓰지 않았다(테스트 설계대로 디코이가 PATH 에서 실제 바이너리보다 앞서고, `env -i` 로 소켓 변수도 없음).
## 5. 공유 제어 평면에 주는 영향
- 생성 shim 은 lib.sh 를 source 하는 모든 실행에서 `<워크스페이스>/.mam/shim/herdr` 로 다시 쓰이므로, 머지 뒤 실제 제어 평면의 shim 도 두 줄이 바뀐다. 바뀌는 것은 "조상 이름에 `-shim` 이 든 PATH 항목을 더 이상 건너뛰지 않음" 뿐이고, `.mam/shim` 과 자기 이름이 `*-shim` 인 디렉터리는 계속 건너뛴다. 내 pane 의 PATH 에서 `-shim` 을 포함하는 항목은 `~/.cmuxterm/cmux-cli-shims/<uuid>` 하나인데(`herdr` 없음, `amp claude codex hermes pi` 만) 전후 어느 규칙에도 해당하지 않아 영향 없다. 본 체크아웃의 `.mam/shim/herdr` 는 이 리뷰 중 건드리지 않았다(아직 base 내용).
- 비차단 메모 1: README(`README.md:102`, `README.ko.md:84`)가 설명하는 레거시 레이아웃 `/tmp/multi-agent-herdr-shim/<server>/herdr` 는 디렉터리 자신의 이름이 서버명이라, 생성 shim 의 새 규칙으로는 제외되지 않는다(lib.sh 의 `_is_shim_path` 는 패턴 상수로 계속 제외). 현재 코드는 그 레이아웃을 만들지 않고 이 호스트에도 없으므로 동작 영향은 없지만, 생성 shim 에도 `*"/multi-agent-herdr-shim/"*` 를 두거나 README 를 현재 레이아웃(`.mam/shim`)으로 고치면 깔끔하다.
- 비차단 메모 2: `mam_sandbox` 는 `HERDR_SESSION`(바이너리가 아는 변수, 내 pane 도 내보냄)을 지우지 않는다. 가짜 HOME 아래 경로로 풀리므로 실제 서버 누출은 아니지만, `lib.sh:1425 _herdr_caller_server` 와 `pane_lock.caller_server` 가 이 값을 "호출자 서버" 로 읽어 pane 안/밖에서 테스트 분기가 달라질 수 있다. 결정성을 위해 함께 지우는 것을 권한다(이 PR 의 범위 밖).
## 6. 잔류물
- scratch 복사본·basetemp·디코이·로그는 모두 세션 scratchpad 아래. 일회용 HOME `/tmp/j639-h` 제거(서버를 띄운 적 없음). 실제 `.mam`·`.mam.env`·모니터·실제 서버(multi-agent-mux, default)·`~/.config/herdr`·에이전트 홈은 건드리지 않았다.
## 결론
두 원인 판정은 코드와 실측(특히 `HERDR_SOCKET_PATH` 가 `--session` 보다 우선)으로 확인되고, 수정은 각각을 막으며 회귀 테스트는 수정 전 코드에서 정확히 그 이유로 실패한다. 기존 래퍼 디렉터리 제외는 유지되고, mock 없이 돌아가는 두 테스트 파일은 herdr 바이너리를 아예 부르지 않는다.
[VERDICT: PASS]
@@ -0,0 +1,111 @@
# 🔍 Review Report — PR #43 `fix/issue-26` 리뷰 (job `645adb2a`)
- **Reviewer**: `planner-reviewer-claude-01` (role: planner,reviewer) · 2026-10-01
- **대상**: PR #43 @ `b58edac`, base `main e1eb22b` (v4.8.0). 6커밋, 17파일 `+1590/−94`, `git diff --check` 클린.
- **검증 경로**: `/var/folders/.../T/mam-review/loop-20261001T131705Z-a60092/1`. **identity 확인**: `HEAD = b58edace…4893`, `HEAD^{tree} = 14b670fb…6ffa` — 브리프와 일치.
- **안전 규칙 준수**: 검증 경로·소스 워크트리·본 체크아웃(`main e1eb22b`) 모두 수정·커밋·checkout 없음. 실제 `.mam.env` 는 **값을 출력하지 않고** 형태만 셌다(`$`·`export`·인라인 `#`·따옴표 줄 수 = 모두 0). 파서 실험은 scratchpad 의 합성 파일로만. 테스트는 브리프대로 `.mam.env` 를 export 하지 않고(`MQTT_*` 미설정 확인) 돌렸다. 실제 `.mam`·데몬·세션·herdr 조작 없음, push·merge·PR 상태 변경 없음.
- **참고(이 잡의 liveness)**: 이 잡의 `started` 발행 때 브로커 `100.69.147.175:1883` 이 연결을 거부했다(`Connection refused`, 지금도 포트 닫힘). `publish_event` 는 rc 2 였지만 레코드는 `running`·`events.log` 에 `started`(토큰 포함)가 기록됐고 heartbeat 타이머도 떠 있다 — #18 계약상 로컬 증거로 충분하다. 브로커 쪽은 오케스트레이터 확인이 필요하다(아래 판정과 무관).
## 결론 요약
**병합을 막을 결함은 없다.** 두 로더는 같은 입력에서 같은 결과를 내고(합성 파일 15줄 실측), 래퍼의 eval 경로는 어떤 값도 셸 해석하지 않으며(명령 치환 주입 실험: 실행 0), bash `mam_int` 와 Python `_positive_int` 는 15가지 입력에서 결과가 같다. 로컬 브로커로 옮긴 시험들은 skip 을 fail 로 바꾸고 양쪽 로그·감사 기록까지 단언해 **오히려 강해졌다**. 비차단 관찰 5건(R1–R5).
## 1. 파서 규칙 (검토 항목 1)
`envfile.py:45-73` `parse`, `mqtt_common.py:98-101` 은 `envfile.apply` 에 위임 — Python 쪽 로더는 **하나**다. 래퍼는 같은 파일을 `--shell` 로 실행한다. 같은 합성 파일을 두 경로에 넣은 결과:
| 입력 줄 | Python 로더 | 래퍼 eval 후 |
|---|---|---|
| `BOMKEY=1` (UTF-8 BOM) | 건너뜀(invalid key, 1행 경고) | 미설정 |
| `A = spaced \r` (CRLF) | `'spaced'` | `spaced` |
| `B="q's"` | `"q's"` | 동일 |
| `C='x''` | `"x'"`(바깥 한 쌍만) | 동일 |
| `D=a=b=c` | `'a=b=c'` | 동일 |
| `E=` | `''` | `''` |
| `export F=exp` | 건너뜀(invalid key) | 미설정 |
| `G=val # trailing` | `'val # trailing'` | 동일 |
| `H=$(touch …/PWNED) \`id\` ${HOME}` | 글자 그대로 | 글자 그대로, **PWNED 파일 생성 안 됨** |
| `9BAD=x` / `noequals` | 건너뜀 + 경고 | 미설정 |
| `IFS=x` / `BASH_ENV=x` | 건너뜀(reserved) | 미설정 |
| `I="unbalanced` | `'"unbalanced'` | 동일 |
| `J= "inner"` | `'inner'` | 동일 |
**두 경로가 완전히 일치한다.** 경고에는 값이 찍히지 않는다(줄 번호·사유만). 이전 Python 로더(`strip('"').strip("'")`)는 따옴표를 여러 겹 벗기고 키 검사가 없었는데, 이제 한 쌍만 벗기고 형식 오류를 경고한다 — 문서화된 규칙과 맞다.
**R1 (비차단, 문서)**: `.mam.env.example` 은 `$HOME/...` 예시를 `/home/you/...` 로 바꿔 "셸 해석 없음" 과 **일관**되게 했지만, 머리말에 규칙 자체(셸 확장 없음, 인라인 `#` 은 값의 일부, `export` 접두어 불가, BOM 저장 시 첫 줄 무시, **이미 있는 환경변수가 우선**)를 적지 않았다. 특히 래퍼 사용자에게는 세 가지가 **동작 변경**이다 — 전에는 bash `source` 가 `export KEY=`·`$HOME` 확장·인라인 주석을 받아들였다. 실제 `.mam.env` 에는 해당 줄이 없음을 확인했다(형태만 셈).
## 2. 래퍼의 eval 경로 (검토 항목 2)
`multi-agent-mux-delegate-job:41-46`: `python -I envfile.py --shell` 의 출력을 `eval`. 출력은 `envfile.py:97-98` 의 `print(f"export {key}={shlex.quote(val)}")` 뿐이고, `key` 는 `_KEY.fullmatch` 를 통과한 것만, `val` 은 `splitlines` 로 개행이 없고 `shlex.quote` 로 감싸진다 — 주입 실험에서 `$()`·백틱·`${}` 모두 글자 그대로 남았다.
- 래퍼가 그 전에 세운 비-export 변수는 `SCRIPT_DIR`·`REPO_ROOT`·`TARGET_ENV` 셋뿐이고, 셋 다 환경으로 넘겨 파일이 덮어쓸 수 없다(`:42`). env-wins 실측: `D=fromenv` 가 파일의 `D=a=b=c` 를 이긴다.
- 실패 경로: 파일이 **없으면** rc 0·출력 없음, **읽을 수 없으면** stderr `WARNING: … cannot read (PermissionError)` 후 rc 0 — 래퍼는 설정 없이 계속한다. 래퍼의 `|| { echo "ERROR: cannot read $TARGET_ENV"; exit 1; }`(`:44`)는 **Python 실행 자체가 실패할 때**(인터프리터 없음 등)만 걸려, 메시지와 실제 원인이 어긋난다(R2, 사소).
- `-I`: `PYTHONPATH`·사용자 site 를 무시하지만 `envfile.py` 는 표준 라이브러리만 쓰고 파일 경로로 실행되므로 문제없다. 오히려 `.mam.env` 를 읽기 전 환경의 `PYTHON*` 이 파서에 끼어들 여지를 없앤다.
## 3. 우선순위 변경의 영향 (검토 항목 3)
전에는 래퍼의 `source` 가 환경을 덮어썼고 Python 로더는 환경을 지켰다 — 같은 파일이 두 경로에서 다른 결과를 냈다. 이제 둘 다 env-wins.
- **루프 러너**가 래퍼에 넘기는 env(`DELEGATE_JOB_PYTHON`, `--registry-dir` 대신 쓰는 `MAM_JOBS_DIR` 등): 전에는 파일 값에 덮일 수 있었다 → 이제 러너 값이 이긴다. **개선**.
- **워크트리 creator**: create/resume 이 넘기는 절대경로 바인딩(`AGENT_SESSIONS_YAML`·`MAM_JOBS_DIR`·…)이 이제 파일 값에 덮이지 않는다 — #21 의 의도와 맞는 **개선**.
- **원격 러너**(`remote.py`)는 원래 Python 로더라 변화 없음. **테스트**는 `conftest` 가 `MAM_ENV_FILE` 을 없는 파일로 고정해 무관.
- 동작이 나빠지는 경로: 셸에 **오래된 `MQTT_*` 를 export 해 둔 채** 파일만 고친 사용자는 이제 파일 값이 적용되지 않는다. 이 부분이 R1 의 문서 누락과 겹친다.
## 4. 정수 정규화 (검토 항목 4)
bash `mam_int`(`lib.sh:2345-2356`)와 Python `_positive_int`(`runner.py:71-82`)를 같은 15개 입력에 돌린 결과 **전부 일치**: `' 30 '→30`, `0700→700`(8진 아님), `0900→900`, `0→거부(min 1)`, `-5`·`+5`·`1e3`·`30s`·전각 `30`·빈 값 → 거부, `999999999→수용`, `1000000000→거부`(9자리 상한), `0000000000000000001→1`(앞 0 제거 후 길이 판정).
- 래퍼: 환경 노브는 `mam_int_knob`(잘못되면 경고 + 기본값, `:154-156`), 플래그는 `parse_opts` 끝의 루프(잘못되면 `ERROR … not a positive decimal integer` rc 1, `:196-200`). `MAM_INJECT_ACK_GRACE_S` 는 min 0(0 = 대기 없음 허용, 의미상 맞다), `ACK_TIMEOUT`·heartbeat·budget 은 min 1. lib.sh `SKS_*` 대기 노브는 min 0(대기 생략 허용), `delegate_wait_started` 도 0 허용(`:2379`).
- 빠진 노브: `$(( ))` 안에 환경 기본값을 직접 쓰는 자리는 남아 있지 않다(B5 정적 검사가 고정). `[ -ge ]`/`[ -le ]`/`sleep` 에만 쓰이는 노브(`MAM_READY_EMPTY_GIVEUP`·`SKS_EMPTY_GIVEUP`·`SKS_IDLE_RETRIES`·…)는 `test` 의 비교가 원래 10진수라 8진 문제가 없고, PR 이 표로 분류해 "모든 정수 노브가 분류됨" 을 시험한다(`test_b5_every_integer_env_default_is_classified`). 그 노브들에 숫자가 아닌 값이 오면 `[` 가 오류(rc 2)를 내는 기존 동작은 남는다(R3, 범위 밖).
## 5. 로컬 브로커와 이전된 시험 (검토 항목 5)
`tests/_local_mqtt.py` 는 MQTT 3.1.1 의 필요한 부분을 클라이언트가 기대하는 순서로 처리한다: CONNECT(레벨 3/4만, 그 외 CONNACK rc 1)→CONNACK, SUBSCRIBE→SUBACK(QoS 최대 1 부여) 뒤 **매칭되는 retained 전달**, PUBLISH QoS1 은 **라우팅·retained 저장 뒤에 PUBACK**(b58edac — 발행자가 ack 를 받은 시점에 브로커 상태가 이미 반영됨), 빈 payload retained 는 삭제, UNSUBSCRIBE→UNSUBACK, PINGREQ→PINGRESP, `+`/`#` 와일드카드와 `$` 토픽 예외(`topic_matches`). 미지원(QoS2·will·지속 세션)은 이 저장소가 쓰지 않는 기능이다. `127.0.0.1:0` 에 바인딩해 **포트 충돌이 없고**, 스레드는 daemon 이며 `stop()` 이 리스너·연결을 닫는다.
이전된 시험의 단언은 **약해지지 않았고 강해졌다**:
- `test_acceptance5`: 공개 브로커용 `_skip_if_broker_did_not_deliver`·`no CONNACK` skip 이 삭제되고, 대신 러너 로그의 `DISPATCH`·`published started/completed`·`DONE rc=0` 과 오케스트레이터 감사 기록의 `dispatch_verified`·`dispatch_fingerprint == sha256(token)` 을 양쪽에서 단언한다.
- `test_issue19`: "브로커 도달 불가면 skip" 이 `pytest.fail` 로 바뀌었다(로컬 브로커는 항상 떠 있으므로).
- S9/S10 의 `_live_broker()`(2·3회차 리뷰에서 내가 P2 로 지적한 공개 브로커 의존)가 `local_broker_env()` 로 대체됐다.
- `test_b19` 는 `send_keys_safe` 본문을 앞 2500자가 아니라 **함수 끝까지** 검사하게 고쳐졌다(정확성 개선).
- 정적 검사(`test_local_mqtt.py:215`)가 공개 브로커 주소의 재유입을 막는다. 시험 로그에 `hivemq`·실제 브로커 IP 흔적 0건.
## 6. 테스트 실행 (검증 방법·검토 항목 6)
```
cd <검증 경로> && AGENT_PYTHON_BIN=<pr-43 .venv> PYTHONDONTWRITEBYTECODE=1 <pr-43 .venv> -m pytest -p no:cacheprovider -q -rfEs \
--basetemp=<밖> tests/test_issue26_envfile.py tests/test_issue26_ints.py tests/test_local_mqtt.py \
tests/test_issue18_liveness_contract.py tests/test_issue33_budget.py tests/test_issue30_stall_watch.py tests/test_tier1_unit.py
(.mam.env 미export, MQTT_* 미설정)
→ 228 passed, 1 skipped in 525.44s (0:08:45) failed 0 (skip = S6, /proc)
```
- PR #41 리뷰 내내 공개 브로커 때문에 흔들리던 S9·S10·R2·B3·acceptance 계열이 **외부 브로커 없이 전부 통과**했다 — PR 의 주장 확인.
- **기준 실패 주장(검토 항목 6)**: `tests/test_issue10_13_purge.py` 를 이 PR 트리와 base(`e1eb22b` 를 `git archive` 로 풀어 놓은 사본)에서 각각 단독 실행한 결과 **둘 다 23 passed**, 실패 0. 즉 이 호스트에서 이 리뷰어의 조건으로는 "12건 기준 실패" 가 **재현되지 않는다**. 양쪽 결과가 같으므로 어느 경우든 PR 회귀는 아니다. 오케스트레이터의 전체 스위트에서만 나타난다면 시험 간 순서·상태(예: 실제 홈의 대화 파일, 이전 시험이 남긴 환경) 의존을 의심할 만하다(R4).
- 격리: 실제 `.mam/jobs` 는 이 잡 자신의 파일 외 **추가 0**. 검증 경로 tracked 파일 변화 없음(HEAD `b58edac`), 본 체크아웃 `main e1eb22b`. 실행 뒤 호스트에 남은 `stall_watch`(이 잡을 감시하는 오케스트레이터의 것)·`sleep 300`(다른 세션의 프로세스 그룹) 은 내 실행과 무관함을 부모/프로세스 그룹으로 확인했다.
## 7. 설계 경계 (검토 항목 7)
- **판정이 Python 으로 이동**: `.mam.env` 해석(키 검증·예약 변수 거부·우선순위)이 bash `source` 에서 `envfile.py` 로 옮겨졌다 — #17 항목 4 의 "`.mam.env` 파싱(#26) — bash `source` 제거" 그대로다. 래퍼에는 실행과 `eval` 만 남는다.
- **bash 에 새로 생긴 로직**: `mam_int`/`mam_int_knob` 과 `parse_opts` 의 플래그 검증. 이는 bash 산술로 들어가는 값을 그 자리에서 정규화하는 **입력 정제**이고, Python 쌍둥이(`_positive_int`)와 한 규칙으로 고정됐다(B1 시험). 판정(무엇을 할지)이 아니라 형식 변환이라 경계 위반으로 보지 않지만, 1회차 PR #41 리뷰의 M4(`MAM_JOB_TIMEOUT_S` 검증이 bash 에 있음)와 같은 부류이므로 항목 4 에서 래퍼 인자 처리가 Python 으로 갈 때 함께 옮길 목록에 둘 것을 권한다(R5).
## 8. 판정
버전 락스텝 bump 부재는 판정에서 제외한다. 병합을 막을 결함은 없다.
| # | 비차단 관찰 | 근거 |
|---|---|---|
| R1 | `.mam.env.example` 머리말에 새 파싱 규칙과 **env-wins** 가 적혀 있지 않다 — 래퍼 사용자에게 `export` 접두어·`$` 확장·인라인 주석·우선순위 4가지가 동작 변경 | `.mam.env.example:1-20`; §1·§3 |
| R2 | 래퍼의 `ERROR: cannot read $TARGET_ENV` 는 Python 실행 실패에만 걸리고, 실제 읽기 실패는 경고 후 설정 없이 진행 — 메시지가 원인과 어긋난다 | `multi-agent-mux-delegate-job:44`, `envfile.py:52-53` |
| R3 | `[ -ge ]`/`sleep` 전용 정수 노브에 숫자가 아닌 값이 오면 `[` 오류(rc 2)를 내는 기존 동작은 남는다 | PR 이 분류표로 의도적으로 범위 밖 처리 |
| R4 | "test_issue10_13_purge.py 12건 기준 실패" 가 이 리뷰어 조건(단독, PR·base 양쪽)에서는 재현되지 않음 — 전체 스위트 순서 의존 가능성 | §6 |
| R5 | `mam_int`/플래그 검증은 bash 의 입력 정제 — 항목 4 이전 목록에 추가 권고 | `lib.sh:2345-2364`, 래퍼 `:196-200` |
## 9. 교차검증 가이드
```bash
RP=/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261001T131705Z-a60092/1
PY=/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux-wt/pr-43/.venv/bin/python
git -C "$RP" rev-parse HEAD 'HEAD^{tree}' # b58edac… / 14b670f…
# 합성 .env 15줄을 envfile.apply 와 `-I envfile.py --shell` + eval 양쪽에 → 결과 동일, $(…)/`…` 미실행 (§1 표)
# mam_int vs _positive_int 15개 입력 → 전부 동일 (§4)
# base: git -C "$RP" archive e1eb22b | tar -x -C <tmp> → tests/test_issue10_13_purge.py → 23 passed (PR 도 23 passed)
```
[VERDICT: PASS]
@@ -0,0 +1,112 @@
# 🔍 Review Report — PR #41·#43 후속 `fix/followups-41-43` (job `76ca3b1b`)
- **Reviewer**: `planner-reviewer-claude-01` (role: planner,reviewer) · 2026-10-02
- **대상**: `0051fd6..95eabc6` (8커밋, 27파일 `+397/−88`, `git diff --check` 클린), base `main 0051fd6`.
- **검증 경로**: `/var/folders/.../T/mam-review/loop-20261001T230324Z-b511bf/1`. **identity 확인**: `HEAD = 95eabc69…d5f4f`, `HEAD^{tree} = 46399844…7998` — 브리프와 일치.
- **안전 규칙 준수**: 검증 경로·소스 워크트리(`multi-agent-mux-wt/followups`)·본 체크아웃 모두 수정·커밋·checkout 없음. 되돌린 사본·합성 파일·`ps` 대역은 scratchpad 에서만. 실제 `.mam.env` 를 읽거나 출력하지 않았고 export 하지 않았다. 실제 `.mam`·데몬·세션·herdr 조작 없음, push·merge·PR 상태 변경 없음.
## 결론 요약
**F1–F6 모두 완료 조건을 만족한다. 범위 밖 동작 변경은 없다.** 제품 코드를 바꾸는 항목(F2·F3·F4·F5)의 회귀는 제품 파일만 base 로 되돌린 사본에서 **47건 실패**한다(아래 §7). 테스트만 고치는 항목(F1·F6)은 base 제품 코드에서도 통과하는 것이 정상이며, F1 은 옛 테스트가 날짜에 따라 실패하던 원인을 `ps` 대역으로 직접 재현했다. 비차단 관찰 3건(T1–T3).
## F1 — A8 의 날짜 의존 (`tests/test_issue21_acceptance.py:258-300`)
- 제품 코드(`loop_lock.sh` 의 `mam_lstart`)는 **그대로**다. `loop_lock.sh` 의 diff 는 F4b 의 `MAM_LOCK_STEAL_RETRIES` 한 줄뿐이다.
- 테스트는 마커의 lstart 를 `" ".join(out.split())` 로 써서 제품의 `tr -s ' '` + 앞뒤 공백 제거와 같게 맞췄다.
- 날짜와 무관하게 고정하는 장치: `padded` 파라미터가 PATH 앞에 `ps` 대역을 둔다. 이 대역은 `lstart=` 질의에만 `Thu Oct 1 08:02:03 2026`(두 칸)을 돌려주고, 대역이 실제로 쓰였는지도 단언한다.
- **옛 버그 재현(직접)**: 같은 대역 아래에서 제품 `mam_lstart` 는 `Thu Oct 1 …`(한 칸), 옛 테스트 방식(`.strip()`)은 `Thu Oct 1 …`(두 칸)을 낸다 → 불일치 → 잠금이 "stale" 로 보여 두 번째 루프가 거부되지 않는다. 즉 옛 A8 이 1~9일에 실패한 원인이 정확히 이것이고, 새 `padded` 케이스는 매일 그 조건을 돈다.
- 같은 패턴의 다른 테스트: `test_o3_scoped_guard.py:_get_lstart` 는 이미 `" ".join(...split())` 로 정규화한다. `test_o2_race_free_lock.py:get_lstart` 는 정규화하지 않지만 **어디서도 호출되지 않는 죽은 헬퍼**다(T1, 지우거나 정규화 권고).
## F2 — 진입점의 한 줄 오류 (`mqtt_common.py:129-140` 외 7곳)
- 공통 헬퍼 `load_env_or_report()` 가 `EnvFileError` 를 잡아 `ERROR: <path>: cannot read (<type>)` 한 줄을 출력하고 False 를 돌려준다. 라이브러리 함수는 `sys.exit` 하지 않는다 — 진입점(`publish_event`·`registry`·`job_subscriber`·`cli`·`bench`·`heartbeat`·`remote` 의 `main`)이 `return 1` 한다. `job_subscriber` 는 `RC_ERROR`(=1)로 이름을 썼다.
- 값·파일 내용은 출력되지 않는다(예외 메시지가 경로와 예외 종류뿐).
- 회귀 `test_a1_…[7 진입점 × permission/utf8]` 14건이 base 에서 모두 실패한다.
- reconcile.sh 의 내장 구독자는 여전히 `ensure_env_loaded()` 를 직접 부른다(브리프 목록 밖, 문서화된 그대로).
## F3 — `.mam.env` 세부
- (a) BOM: `envfile.py:60-61` 이 `utf-8-sig` 로 연다. 공통 파서라 **두 로더 모두** 적용된다. 파일 맨 앞의 BOM 만 벗기므로 중간의 U+FEFF 는 그대로다.
- (b) 중복 키는 첫 값 우선: 동작은 그대로(`apply` 가 `if key not in environ`)이고 `.mam.env.example` FORMAT 절에 한 줄 추가했다.
- (c) `MESSAGING.md:369`: "명시한 파일이 없으면 오류" 를 "조용히 건너뜀(주변 탐색은 안 함), 있는데 못 읽으면 EnvFileError" 로 실제 동작에 맞췄다.
- 회귀 `test_a7_*` 3건이 base 에서 실패한다.
## F4 — 정수 노브
- (a) 러너(`runner.py:627`, `:630-643`, `:762-768`): `_int_knob` 이 `_positive_int` 규칙으로 읽고, 잘못된 값이면 노브당 한 번 경고하고 기본값을 쓴다. `MAM_CHILD_WAIT_S=abc` 의 `ValueError` 가 사라졌다.
- 최솟값의 의미도 맞다.
| 노브 | 최솟값 | 의미 |
|---|---|---|
| `MAM_WAIT_GRACE_S` | 0 | 예산만큼 정확히 대기 |
| `MAM_CHILD_WAIT_S` | 0 | 하위 잡을 한 번만 확인 |
| `MAM_JOB_TREE_MAX` | 1 | 0 이면 모든 트리가 "초과"; 대기하지 않으려면 `MAM_CHILD_WAIT_S=0` |
세 값 모두 표 `RUNNER_INT_KNOBS` 에 들어갔다.
- (b) bash 노브: `MAM_READY_EMPTY_GIVEUP`·`SKS_EMPTY_GIVEUP`(min 1), `SKS_IDLE_RETRIES`·`SKS_IDLE_RETRY_BACKOFF_S`(min 0), `MAM_LOCK_STEAL_RETRIES`(min 0), `MAM_DIFF_MAX_BYTES/LINES`(min 1), `RECONCILE_POLL_INTERVAL`(min 1 — 0 이면 바쁜 루프)가 `mam_int_knob` 을 거친다.
- `SKS_QUIESCENT_INTERVAL` 은 기본값이 소수(0.5)라 **그대로 두고 표에 사유**를 남겼다 — 브리프 지시대로다.
- `RECONCILE_POLL_INTERVAL` 은 저장소 안의 모든 사용처(테스트 `60`, 문서 15 s)가 정수라 의미가 깨지지 않는다.
- `diff_collect.sh`·`loop_lock.sh` 는 lib.sh 없이 source 되므로 규칙을 `lib_int.sh` 로 분리했다. lib.sh 도 같은 파일을 source 한다(`declare -F` 가드로 이중 정의를 막음). `deploy/remove.sh` 의 폴백 자산 목록에 `lib_int.sh` 를 추가한 것은 새 파일을 배포 제거 대상에 넣는 **필수 동반 변경**이다(범위 안). `test_issue21_loop_b.py` 의 한 줄도 L15 가 `diff_collect.sh` 를 복사해 쓰므로 필요한 동반 수정이다.
- (c) loop SKILL.md 의 Job budget 행에 `MAM_WAIT_GRACE_S`(기본 300, 0 이상, 잘못된 값은 경고 후 300)를 문서화했다.
- 회귀 `test_b1_runner_*`·`test_b5_*` 가 base 에서 실패한다.
## F5 — 테스트 스위치 제거 (`stall_watch.py`, `test_issue30_stall_watch.py`)
- `MAM_TEST_UNRELIABLE_AGENT` 두 줄을 삭제했다.
- S13 은 `-c` 진입점에서 어댑터 클래스의 `agent_status_reliable` 을 `False` 로 바꾼 뒤 **실제** `stall_watch.main` 을 돈다. 제품에는 분기가 없고, 검증하는 내용(idle 판정 끔, blocked 알림 유지)도 같다.
- 새 `test_s13_the_unreliable_case_needs_no_switch_in_the_product`(`MAM_TEST_` 문자열 부재)가 base 에서 실패한다.
## F6 — 문서·테스트 정밀도
- delegate-job SKILL.md:53 은 이제 "≤ 60 s 는 herdr wait 한정, 알림 **발행 중**에 래퍼가 사라지면 그 발행이 끝날 때까지" 라고 적는다 — 코드(`_wrapper_gone` 이 wait 직후와 `_may_prompt` 에만 있음)와 일치한다.
- "대기 중 래퍼 소멸" 테스트는 이제 **두 번째 wait 대역 안에서** `gone` 을 세우고, `ticks == [1, 2]` 와 마지막 alive 관측이 `(2, False)` 임을 단언한다. 이름 그대로를 강제한다.
- 테스트 정밀화라 base 제품 코드에서도 통과하는 것이 맞다(제품 동작은 PR #41 에서 이미 옳았다).
## 7. 테스트
### 7-1. 검증 경로에서의 실행
```
tests/test_issue26_envfile.py tests/test_issue26_ints.py tests/test_issue21_acceptance.py tests/test_issue30_stall_watch.py
tests/test_issue30_stall_watch_stop.py tests/test_tier1_unit.py tests/test_version_consistency.py tests/test_issue5_phase1_delegation.py
(.mam.env 미export, MQTT_* 미설정, followups .venv)
→ 335 passed, 1 skipped in 451.38s failed 0 (skip = S6, /proc)
```
브리프 목록 7파일에 `test_issue5_phase1_delegation.py`(registry CLI 스냅샷·help 캡처)를 더해, 진입점 변경이 CLI 계약을 깨지 않음을 확인했다. 로그에 외부 브로커 흔적 0건.
### 7-2. 수정 없이는 실패하는가 — 확인함
검증 경로 사본에서 `tests/` 밖의 바뀐 파일 전부를 `0051fd6` 판으로 되돌리고(새 `lib_int.sh` 는 삭제) 새·변경 테스트를 돌렸다:
```
47 failed, 88 passed in 110.20s
```
| 항목 | base 에서 실패한 회귀 |
|---|---|
| F2 | `test_a1_python_entry_points_report_an_unreadable_file_in_one_line` 14건(7 진입점 × 권한/UTF-8) |
| F3 | `test_a7_a_leading_bom_…`, `…_repeated_key_keeps_its_first_value…`, `…_missing_explicit_file_…` |
| F4 | `test_b1_runner_wait_grace/child_wait/job_tree_max_*`, `test_b5_*`(표·최솟값·lib.sh 없이 source 되는 두 스크립트·`_pane_quiescent`) |
| F5 | `test_s13_the_unreliable_case_needs_no_switch_in_the_product` |
| F1·F6 | 테스트만 고친 항목이라 base 제품 코드에서 통과하는 것이 정상. F1 은 §F1 의 `ps` 대역 실험으로 옛 테스트의 실패 원인을 직접 재현 |
### 7-3. 격리
실제 `.mam/jobs` 는 이 잡 자신의 파일 외 **추가 0**. 검증 경로 tracked 파일 변화 없음(HEAD `95eabc6`), 본 체크아웃 `main 0051fd6`.
## 8. 범위
바뀐 파일 27개를 모두 F1–F6 또는 그 필수 동반 변경으로 귀속할 수 있다.
- `lib_int.sh` 분리와 그에 따른 `deploy/remove.sh`·`test_issue21_loop_b.py` 의 한 줄은 F4b 의 동반 변경이다.
- #17 항목 4·#39·#40·#42 범위의 변경과 버전 bump 는 없다.
## 9. 판정
| # | 비차단 관찰 | 근거 |
|---|---|---|
| T1 | `test_o2_race_free_lock.py:get_lstart` 는 정규화하지 않는 **호출되지 않는** 헬퍼 — 지우거나 같은 정규화로 맞추면 재발 여지가 없다 | `tests/test_o2_race_free_lock.py:40-43` |
| T2 | reconcile.sh 의 내장 구독자는 `ensure_env_loaded()` 를 직접 불러 읽기 실패 시 여전히 트레이스백 | 브리프 진입점 목록 밖; 다음 정리 때 `load_env_or_report` 로 |
| T3 | (확인 완료, 조치 불필요) `deploy/install.sh` 는 트리 전체를 복사하므로(`cp -R "$REPO_URL/."`, :128) `lib_int.sh` 도 함께 배포된다. `check_assets_present`(:98-108)는 존재 확인용 목록이라 `lib.sh` 만 있어도 되지만, lib.sh 가 lib_int.sh 를 source 하므로 목록에 추가해 두면 부분 설치를 더 일찍 잡는다 | `deploy/install.sh:98-108` |
[VERDICT: PASS]
@@ -0,0 +1,95 @@
# 리뷰 리포트 — PR #59 2차 (Issue #31) / job 778fc866
- 리뷰어: planner-reviewer-claude-01 (Opus 5.5)
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261017T135731Z-e93f0a/1`
- identity: HEAD `2a8d73825b3832376aeaf96b09548359dc3cb887`, tree `d146f1ac68bcc3485d24fb92edb87264e4d1e8cb` 일치. 트리는 clean이고 `.mam` 없음(테스트 후에도 동일).
- 범위: 26e2c43..2a8d738 누적, 중점은 1차 이후 커밋 11개(26fb1c4..2a8d738)
## 요약
1차 수정 요청 3건(차단 F1, P1, P2)은 **제품 코드에서 모두 해결됐다**. 비차단 메모(서머타임/UTC, exit 4 안내, SKILL.md 문구, 테스트의 실제 `/tmp` 나열)도 해결됐다. 샌드박스 재현과 변형(mutation) 대조로 각 수정과 회귀 테스트가 실제로 맞물리는 것도 확인했다.
그러나 **새로 추가된 테스트 `test_any_spelling_of_the_uuid_is_the_lowercase_session[upper|mixed]` 는 이 Mac 에서 항상 실패한다**(T1). 원인은 제품 코드가 아니라 테스트의 macOS 이식성 버그다. 이대로 병합하면 이 Mac 의 스위트가 빨간불이 되므로 NOT PASS 로 판정한다. 수정은 테스트 한 줄이다.
## 차단 결함
### T1. `sh -c "sleep 120" <uuid>` 보유자 프로세스가 macOS 에서 uuid 를 argv 에서 잃는다
- 위치: `tests/test_issue31_purge_residual.py:315-318`
```python
named = subprocess.Popen(["sh", "-c", "sleep 120", conv])
time.sleep(0.05)
assert adapter.conversation_in_use(other, ctx) is True, "a command line naming the lowercase id"
```
- 원인: 이 Mac 의 `/bin/sh` 는 GNU bash 3.2.57 이다. bash 는 `-c` 의 단일 단순 명령을 exec 로 바꿔 실행하므로, 실행된 프로세스의 argv 는 `sleep 120` 만 남고 `$0` 인 uuid 는 사라진다.
- 직접 확인: `/bin/sh -c 'sleep 30' MARKER-abc &` → `ps` 에 `sleep 30` 만 보인다.
- `/bin/sh -c 'sleep 30; :' MARKER-abc` → `/bin/sh -c sleep 30; : MARKER-abc` 로 uuid 가 남는다.
- 이 저장소도 같은 함정을 이미 기록해 두었다: `tests/test_issue10_13_purge.py:114` ("a shell execs its last simple command and the uuid …").
- 재현: 검증 경로의 관련 모듈 실행(아래)에서 **2 failed**. 수정하지 않은 `git clone` 사본에서 `-k any_spelling` 만 돌려도 2 failed:
`AssertionError: a command line naming the lowercase id … conversation_in_use('91151A0D-0114-4C3C-a120-aec043818d1f', …) → False`
아래 변형 대조 4종(F1/P1/P2/UTC)에서도 변형과 무관하게 같은 2개가 실패했다.
- 제품 로직은 정상이다. argv 를 유지하는 보유자(`["sh","-c","sleep 30; :", <lowercase uuid>]`)로 샌드박스에서 확인했다:
- `conversation_in_use(UPPER)` = True, `conversation_in_use(lower)` = True.
- 단일 명령 보유자로는 두 철자 모두 False.
- 제안: 보유자를 `["sh", "-c", "sleep 120; :", conv]` 나 `[sys.executable, "-c", "import time; time.sleep(120)", conv]` 처럼 uuid 가 argv 에 남는 형태로 바꾼다. 고정 `time.sleep(0.05)` 대신 짧은 폴링(예: 최대 2 s 동안 `ps` 에 보일 때까지)으로 기다리면 느린 호스트의 경합도 피할 수 있다.
## 1차 지적사항 확인
샌드박스 조건: HOME·CLAUDE_PROJECT_DIR·CLAUDE_CODE_TMPDIR·TMPDIR 를 모두 스크래치패드 아래로 두고, CLAUDE_CONFIG_DIR 은 비웠다. 인터프리터는 `/usr/bin/python3` 3.9.6. 실제 경로는 읽기만 했다.
1. **[차단] 비-UUID id — 해결.**
- 방식: `_canonical()`(`claude.py:8-19`)이 정규 UUID 만 받아 소문자로 바꾼다. `_candidates`·`purge_artifacts`·`residual_artifacts`·`conversation_in_use` 의 모든 입구가 이 함수를 거친다.
- 재현: 아래 14개 id 는 purge 결과가 모두 rc 5 이고, 아무것도 지우지 않았다. `projects/<key>/memory/MEMORY.md` 와 다른 세션 파일은 전부 남았다.
- `""`, `.`, `..`, `a/b`, `*`, `../x`
- `memory`, `MEMORY`, `subagents`, `<key>`
- uuid 앞 8자리, `<U>x`, `" <U>"`, `"<U>\n"`
- 대문자 uuid 로 purge 하면 소문자 경로 9개가 정확히 지워진다(`lower jsonl gone True`). 다른 세션·공유 파일은 모두 보존됐다.
- 변형 대조(`_canonical` 을 옛 `_plausible_id` 규칙으로 되돌림): `rejects_malformed_uuid[memory, ' ', notes, …g, '{conv} ', 'x{conv}']`, `a_non_uuid_id_reaches_nothing[uuid-c, memory, notes]`, `any_spelling` 이 실패해 **11 failed**.
2. **[P1] 링크 경계 — 해결.**
- 방식: `_inside()`(`claude.py:109-123`)는 루트와 대상 사이의 디렉터리 구성요소를 `lstat` 으로 하나씩 확인하고, 부모의 realpath 가 루트의 realpath 아래에 있는지 본다.
- 링크로 연결된 tmp `<key>`, `file-history/`, `projects/<key>` 뒤의 내용은 모두 보존되고 `residual` 로 보고된다.
- 말단 링크(`session-env/<U> → 외부`)는 링크만 지워지고 대상 파일은 남는다.
- 루트 자체가 링크인 경우(`~/.claude → realhome`, `CLAUDE_CODE_TMPDIR → realtmp`, 즉 `/tmp → /private/tmp` 같은 형태)는 정상적으로 purge 된다.
- Python 3.9 macOS 의 `shutil._use_fd_functions` 는 True 로, rmtree 가 내부 링크를 따라가지 않는다.
- 변형 대조(`_inside` → 항상 True): `never_follows_a_linked_dir_below_a_root`, `reports_a_linked_orphan_it_will_not_remove` 실패(T1 의 2개 포함 **4 failed**).
3. **[P2] 유한하지 않은 startedAt — 해결.**
- 방식: `claude.py:84-96`.
- 살아 있는 `sleep` 보유자에 대해 startedAt 을 아래 9가지 값으로 바꿔 봤다. 모두 in-use rc 4, purge rc 4 였고 jsonl 은 보존됐다.
- `NaN`, `Infinity`, `-Infinity`, `"NaN"`, `true`, `1e400`, 400자리 정수, `null`, `"1760000000000"`
- 정상 ms 값이면 live(rc 4), 1시간 전 값(pid 재사용)이면 free 다.
- 변형 대조(`isfinite` 제거): `unusable_started_at_means_live[nan]`, `live_holder…[NaN]` 실패. inf 계열은 다른 분기에서도 live 로 판정되므로 이 변형으로는 실패하지 않는 것이 맞다.
## 비차단 메모 확인
- **UTC (af8ed39)**: 실제 살아 있는 claude 4개를 읽기 전용으로 비교했다(`TZ=UTC0 LC_ALL=C ps -o lstart=` + `calendar.timegm`).
- lstart 가 세션 파일의 `procStart`(UTC)와 초 단위까지 일치했다. 예: `Sat Oct 17 13:55:10 2026`.
- startedAt − lstart 는 1.34 / 1.49 / 1.99 / 2.36 s 로, 모두 live 판정이다.
- 변형 대조(mktime·로컬 TZ 로 되돌림): `liveness_reads_lstart_as_utc` 실패.
- **exit 4 안내 (e156179)**: in-use stderr 가 두 가지를 출력한다.
- "a claude process whose session file under <cfg>/sessions/ names it" (CLAUDE_CONFIG_DIR 을 반영)
- "ps -axww | grep <uuid>"
- `stop_session.sh:292` 의 `h="$(… 2>&1 >/dev/null)" || rc=$?` 는 `/bin/bash` 3.2 + `set -euo pipefail` 에서 rc 4 와 안내 문구를 올바르게 잡고, 안내가 비었을 때도 스크립트가 계속 진행된다(직접 확인). `bash -n` 통과.
- **SKILL.md exit 4 문구**: "Records preserved … the session itself may already be down" 으로 정확해졌다.
- **테스트의 실제 /tmp (26fb1c4)**: autouse 픽스처가 `_SYSTEM_TMP` 를 테스트 디렉터리로 패치하고, `test_tests_never_reach_the_real_tmp_root` 가 하위 프로세스까지 확인한다.
- **agy 게이트**: 동작 변화는 그대로다(fail-closed). 실제 agy 로는 확인하지 않았다(브리프상 금지).
## 회귀 관점 (dd292c1 등)
- `has_artifacts` 는 링크 뒤의 후보도 포함하므로 `purge auto` 가 claude 를 고른다. 이때 purge 는 아무것도 지우지 않고 residual 만 보고한다(위 links 재현: rc 0, `purged` 에는 말단 링크 1개만 있고 `residual` 4개). 의도대로다.
- 동작이 바뀌는 지점이 하나 있다(비차단): `projects/<key>` 자체가 심볼릭 링크인 사용자는 이제 transcript jsonl 이 purge 되지 않고 residual 로만 보고된다. 반면 `discover`/`verify_artifact` 는 링크를 따라가 그 대화를 계속 찾는다. P1 요구("링크를 따라가지 않음")의 결과이고 residual 로 드러나므로 수용 가능하지만, SKILL.md 에 한 줄 적어 두면 좋다.
- 철자 비대칭(비차단): 소문자 id 로 질의하면 대문자 argv 보유자는 보지 못한다(`claude -r <UPPER>` 로 띄운 경우). claude 가 소문자로 기록하는 한 실질 영향은 없다.
- `base.in_use_hints` 추가와 `__main__` 의 in-use stderr 출력은 다른 어댑터에 기본 안내만 준다. 회귀 없음.
- `test_a4_adapter_contract` 의 claude id 를 실제 uuid 로 바꾼 것은 새 계약(정규 UUID 만 허용)에 따른 정당한 갱신이다.
- Python 3.9 파싱(`feature_version=(3,9)`)과 `/usr/bin/python3` 실행(모든 프로브)에서 문제 없었다.
## 테스트
- 검증 경로, `env -i`(HERDR_*·WORKSPACE_ROOT·MAM_*·DELEGATE_JOB_LOGS_DIR·CLAUDE_CONFIG_DIR 미설정), 스크래치패드 basetemp, `PYTHONDONTWRITEBYTECODE=1 -p no:cacheprovider`.
- 실행 모듈: `test_issue31_purge_residual, test_issue10_13_purge, test_a4_adapter_contract, test_issue42_skill_blocks, test_issue55_agent_homes, test_orc_onboard, test_uuid_target, test_tier1_unit, test_tier2_component`
- 결과: **2 failed (T1), 330 passed, 1 skipped** (630 s).
- 사본에서 `test_issue31_purge_residual.py` 를 반복 실행한 결과:
- 비변형 사본: 2 failed (T1) / 65 passed.
- P2 변형 사본: 첫 실행에서 `test_grok_residual_reports_memory_observations` 가 한 번 ERROR 였다. 이후 재실행 4회에서는 재현되지 않아 일회성으로 보고만 한다.
- 6f4f40a 의 제품 파일로 통째로 되돌린 대조는 쓸 수 없었다. autouse 픽스처가 26fb1c4 에서 추가된 `claude._SYSTEM_TMP` 를 패치하므로 67개 모두 setup ERROR 가 난다. 그래서 위의 수정별 변형 대조로 대신했다.
[VERDICT: NOT PASS]
@@ -0,0 +1,14 @@
{
"loop_id": "loop-20261013T010208Z-f90dd4",
"base_sha": "325b056057fcdd717d2c81017fc5ef57183cb4d0",
"head_sha": "db7f4cab9c60ff5db009cf3fc625e9a86a465ef6",
"tree_sha": "f32cabd3a6c8d775c9223509967f1a810f0cf827",
"review_commit": "db7f4cab9c60ff5db009cf3fc625e9a86a465ef6",
"reviewer": "planner-reviewer-claude-01",
"job_id": "834fa032",
"verdict": "PASS",
"transport": "herdr",
"evidence": "review-tree",
"isolation": "worktree",
"verified_at": "2026-10-13T01:21:50Z"
}
@@ -0,0 +1,29 @@
# 테스트 실제 herdr 서버 누수 수정 2차 리뷰 — 커밋 db7f4ca (job 834fa032)
- 리뷰어: planner-reviewer-claude-01 (Reviewer)
- 리뷰 identity: loop-20261013T010208Z-f90dd4 / base 325b0560 .. head db7f4cab (커밋 1개, 2 파일) / tree f32cabd3
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261013T010208Z-f90dd4/1` — HEAD·tree 일치, `git status --porcelain` 0 줄(시작·종료), `.mam` 생성 없음. 본 체크아웃 HEAD 8b0b4a0(main) 변동 없음.
- 실행 안전: 1차와 같이 모든 테스트·탐침은 `env -i`(PATH/HOME/LANG/TMPDIR 만, 필요한 변수는 명시 추가)로 돌려 브리프의 `-u` 목록 전부가 비워진다. basetemp 는 scratchpad 아래(이름에 "shim" 없음). 실제 바이너리는 일회용 HOME 에서 `status server` 읽기만(서버 미기동). Python 은 본 체크아웃 `.venv`(3.9.6). 전체 스위트는 돌리지 않았다. 범위는 이 커밋만.
## 1. 변경
- `tests/conftest.py:299`: `mam_sandbox` 가 `HERDR_SESSION` 도 지운다(주석 `:296-297`). 현재 제거 목록: `HERDR_CONFIG_PATH`, `HERDR_PANE_ID`, `HERDR_SERVER_NAME`, `HERDR_SESSION_NAME`, `HERDR_SESSION`, `HERDR_SOCKET_PATH`, `HERDR_TAB_ID`, `HERDR_WORKSPACE_ID`, `XDG_CONFIG_HOME`.
- `tests/test_herdr_real_server_leak.py:76`: L2 의 `PANE_VARS` 에 `HERDR_SESSION` 추가. 수정 전 conftest(325b056)에 새 테스트를 얹은 scratch 복사본에서 L2 는 `AssertionError: ['HERDR_SESSION']` 로 실패 → 테스트가 이 변경을 정확히 겨눈다.
- Python 3.9 컴파일 통과. 제품 코드 변경 없음.
## 2. HERDR_SESSION 을 지워서 깨지는 테스트가 있는가 (검토 관점 1)
- 소비자: `lib.sh:1425 _herdr_caller_server`, `lib_py/pane_lock.py:49 caller_server`, `lib_py/loop/watcher.py:64` — 셋 다 "있으면 호출자 서버, 없으면 `HERDR_SOCKET_PATH` 모양" 이고 `HERDR_SOCKET_PATH` 는 이미 지워지므로 샌드박스에서는 일관되게 "pane 밖" 으로 판정된다.
- 테스트 쪽 bare `HERDR_SESSION` 참조는 전부 테스트가 **스스로** 넣는 값이다(픽스처 뒤라 영향 없음): `test_issue23_24_startup_path.py:264,369,395`(S1g·watcher 케이스는 `env={...}` 로 명시), `test_issue34_job_tree.py:580`, `test_issue30_stall_watch.py:486`, `test_issue45_restore.py:42`(`SCOPE_ENV` 로 이미 `HERDR_SESSION`·`HERDR_ENV` 까지 자체 제거). 반대로 "`HERDR_SESSION` 이 없어야" 하는 테스트(`test_s2b…:188`, S2 계열 "pane 밖 호출자") 는 이제 pane 안에서 돌려도 전제가 보장된다 — 이것이 이 변경의 목적이다.
- 실측: 같은 6 파일(`test_herdr_real_server_leak`, `test_issue50_pane_lock`, `test_issue23_24_startup_path`, `test_herdr_shim_contract`, `test_issue45_herdr_config`, `test_issue9_server_env`)을 (a) 깨끗한 환경, (b) 바깥 환경에 `HERDR_SESSION=pane-real-srv` 를 넣은 환경에서 각각 돌렸다: **둘 다 139 passed, 34 skipped** (skip 은 모두 Python 3.9 의 tomllib skip). pane 안/밖 결과가 같다.
## 3. 다른 라우팅 변수가 남았는가 (검토 관점 2)
- 내 pane 이 내보내는 `HERDR_*` 는 `HERDR_BIN_PATH`, `HERDR_CONFIG_PATH`, `HERDR_ENV`, `HERDR_PANE_ID`, `HERDR_SESSION`, `HERDR_SESSION_NAME`, `HERDR_SOCKET_PATH`, `HERDR_TAB_ID`, `HERDR_WORKSPACE_ID` 아홉 개. 샌드박스가 지우지 않는 것은 `HERDR_BIN_PATH`·`HERDR_ENV` 둘뿐이다.
- 실제 바이너리(0.9.3) 에 각각을 단독으로 주고 `status server` 를 읽었다: `HERDR_ENV`, `HERDR_BIN_PATH`, `HERDR_CONFIG_PATH`, `HERDR_WORKSPACE_ID`, `HERDR_PANE_ID` 모두 소켓은 `$HOME/.config/herdr/herdr.sock`(가짜 HOME) — 서버 선택에 영향 없음. 1차에서 확인한 대로 소켓을 바꾸는 것은 `HERDR_SOCKET_PATH`(최우선)와 `HERDR_SESSION`(HOME 상대) 둘뿐이며 이제 둘 다 제거된다.
- `.agents`·`tests` 에서 `HERDR_BIN_PATH`·`HERDR_ENV` 를 읽는 코드는 없다(`test_issue45_restore.py:42` 가 자체 정리 목록에 넣어 둔 것뿐). 남은 라우팅 변수 없음.
## 4. 잔류물
- scratch 복사본·basetemp·로그는 scratchpad 아래. 일회용 HOME `/tmp/j834-h` 제거(서버 미기동). 실제 `.mam`·`.mam.env`·모니터·실제 서버·`~/.config/herdr`·에이전트 홈은 건드리지 않았다.
## 결론
`HERDR_SESSION` 제거는 호출자 서버 판정을 pane 안/밖에서 동일하게 만들고, 그 값을 쓰는 테스트는 모두 자체 설정이라 깨지지 않는다(두 환경에서 동일하게 139 passed). 실제 바이너리의 서버 선택에 관여하는 변수는 이제 모두 샌드박스에서 제거된다.
[VERDICT: PASS]
@@ -0,0 +1,14 @@
{
"loop_id": "loop-20260925T072328Z-2f5853",
"base_sha": "ed293dcef4c346e0050a31cb46812bcdcf1d67cf",
"head_sha": "e80186f56064eaf93a2f32c48d31d84816894774",
"tree_sha": "3501868872d828894f031911b8ca31b01ce77a72",
"review_commit": "e80186f56064eaf93a2f32c48d31d84816894774",
"reviewer": "planner-reviewer-claude-01",
"job_id": "895b0370",
"verdict": "PASS",
"transport": "herdr",
"evidence": "review-tree",
"isolation": "worktree",
"verified_at": "2026-09-25T07:43:24Z"
}
@@ -0,0 +1,85 @@
# 🔍 Review Report — PR #38 델타 리뷰 2회차 (job `895b0370`)
- **Reviewer**: `planner-reviewer-claude-01` (role: planner,reviewer) · 2026-09-25
- **대상 델타**: `ed293dc..e80186f` (2커밋, 3파일 `+73/−10`, `git diff --check` 클린), base `main 7a4a0c2` (v4.7.3).
- **검증 경로**: `/var/folders/.../T/mam-review/loop-20260925T072328Z-2f5853/1`. **identity 확인**: `HEAD = e80186f5…4774`, `HEAD^{tree} = 35018688…7a72` — 브리프와 일치.
- **1회차 내 판정**: PASS + K1~K4 (job `107d435d`). 이번에는 N1/R1(cancelled 사유)·R2(원격 마감 문구)·K1(STRAY_RULE)의 정정을 본다.
- **안전 규칙 준수**: 검증 경로·소스 워크트리·본 체크아웃(`main 7a4a0c2`) 모두 수정·커밋·checkout 없음. 실험은 in-process 러너(`env -u MAM_VERSION`, 임시 등록부·감사 로그)와 되돌린 사본에서만. 실제 herdr·세션·`.mam`·데몬 조작 없음, push·merge·PR 상태 변경 없음.
## 결론 요약
**세 정정 모두 지적을 닫고, 1회차 판정을 뒤집을 요소는 없다.** 종결 사유 12가지를 `_subjob_state` 에 넣어 분류가 설계대로임을 확인했고(감사 ndjson 경로·`.events.log` 폴백 양쪽), `get_feedback` 의 기본 동작은 그대로다. 되돌린 사본에서 T13×2·T14·T6 만 실패한다. 비차단 관찰 1건(L1).
## 1. N1/R1 — `cancelled` 사유 (항목 1)
- `registry.get_feedback(job_id, registry_dir, events=("completed","error"))` (`registry.py:316-317`): 기본값이 종전과 같아 호출자 — 래퍼의 `get-feedback` CLI 3곳(`:556`,`:565`,`:593`)과 `registry.py get-feedback` 서브커맨드(`:668`) — 는 인자 없이 부르므로 **동작 불변**. 실측: `error "first"` 뒤 `cancelled "session_busy"` 가 온 잡에서 기본 호출은 `'first'`, `events=(…,"cancelled")` 는 `'session_busy'`.
- `_subjob_state`(`runner.py:684-702`)만 `cancelled` 를 포함해 읽는다(`:691`).
- 두 경로 모두 확인: `.events.log` 에만 `cancelled` 가 있는 잡 → `NOT-DISPATCHED/target_not_found`; **감사 ndjson 에만**(`.events.log` 없음, `source_event: cancelled`) 있는 잡 → `NOT-DISPATCHED/session_busy`.
## 2. R2 — 첫 단어 판정 (항목 1)
`closed_kind = reason.split(" ", 1)[0]`(`:696`) 을 `ORCHESTRATOR_CLOSED` 와 비교한다. 원격 러너의 실제 문구는 `remote.py:352` 의 `"deadline_timeout (remote runner)"` 다. 오분류 여부를 사유 12가지로 실측:
| 상태·사유 | 분류 |
|---|---|
| `error` `deadline_timeout` (래퍼) | CLOSED-UNCONFIRMED |
| `error` `deadline_timeout (remote runner)` | **CLOSED-UNCONFIRMED** (R2 정정) |
| `error` `agent_blocked` | CLOSED-UNCONFIRMED |
| `error` 에이전트 자신의 사유(`tests failed on 3 cases`) | `error`(실패) — 첫 단어 `tests` 는 튜플 밖 |
| `error` 사유 없음 | CLOSED-UNCONFIRMED (기존 규칙) |
| `cancelled` `target_not_found` / `session_busy` / `rolled back by create_session` | **NOT-DISPATCHED** (N1 정정; `session_busy` 는 첫 단어 판정에 걸리지 않고 cancelled 분기로 간다) |
| `cancelled` 사유 없음 / `operator abort` | `cancelled`(실패 집계) |
| `completed` `all done` | completed |
첫 단어 판정이 `session_busy`·`target_not_found`·`rolled` 등을 미확인 종결로 오분류하지 않는다. 남는 이론적 여지는 에이전트가 **자기 사유를 `deadline_timeout`/`agent_blocked` 로 시작하는 문장으로 쓰는 경우**뿐인데, 이 두 단어는 오케스트레이터 예약어라 실무에서 겹치지 않는다(L1, 정보).
## 3. K1 — `STRAY_RULE` (항목 1)
`runner.py:41-42` 의 문장은 "귀속되지 않은 열린 잡은 `--parent-job` 없이 등록된 이 작업의 하위 위임일 수 있다; 그 산출물이 작업 목표에 필요하면 NOT PASS" 이고, `_subjobs_block` 은 stray 가 있을 때만 stray 줄 뒤에 붙인다(`:727-729`). SUBJOB_RULE(하위 잡이 있으면 대체 완료 입증이 없는 한 NOT PASS)과 **모순되지 않는다** — stray 는 무관한 잡일 수 있으므로 "필요하면" 이라는 조건부가 맞고, 둘 다 같은 방향(미완 → NOT PASS)이다. stray 가 없으면 프롬프트는 1회차와 바이트 동일(`if not rows and not stray: return ""` 유지).
## 4. 테스트 (항목 1·2)
### 4-1. 수정 없이는 실패하는가 — 확인함
검증 경로 사본에서 `registry.py`·`runner.py` 만 `ed293dc` 판으로 되돌리고 `-k "t13 or t14 or t6 or t5"` 를 돌렸다:
```
4 failed, 3 passed, 13 deselected in 22.39s
FAILED test_t13_…[target_not_found] ← N1
FAILED test_t13_…[session_busy] ← N1
FAILED test_t14_the_remote_runners_deadline_wording_is_closed_unconfirmed ← R2
FAILED test_t6_an_unattributed_open_job_is_shown_and_waited_for_only_on_request ← K1 (새 단언)
```
T5/T5b(`deadline_timeout` 정확 일치·늦은 쓰기 무효화)는 양쪽에서 통과 — 기존 분류가 정정으로 바뀌지 않았다. T13·T14 는 실제 `registry.py close` + `publish_event` 로 종결을 만드는 시험이라(mock 아님) 원격 러너의 실제 문구 그대로를 검증한다.
### 4-2. 검증 경로에서의 실행
```
tests/test_issue34_job_tree.py tests/test_issue5_phase1_delegation.py tests/test_issue14_delivery_ack.py
tests/test_loop_cli.py tests/test_issue25_plan_reuse.py tests/test_issue21_loop_b.py
→ 158 passed in 271.04s failed 0 · skipped 0
```
새 파일 20건 전부 통과, `registry.py` CLI 스냅샷·help 캡처(issue5 phase1) 통과 → `get_feedback` 시그니처 확장이 CLI 계약을 건드리지 않는다. 실제 `.mam/jobs` 전후 **차이 0**, 검증 경로 tracked 파일 변화 없음(HEAD `e80186f`), 본 체크아웃 `main 7a4a0c2`.
## 5. 병합 차단 여부 (항목 2)
1회차 PASS 를 뒤집을 요소는 없다. 델타는 `get_feedback` 의 기본값 보존 인자 하나, `_subjob_state` 의 두 줄, `STRAY_RULE` 한 문장, 회귀 3건이며, 정정 대상만 정확히 실패→통과로 바뀌었다. 버전 락스텝 bump 부재(`lib.sh:32` `4.7.3`, 오케스트레이터가 4.7.4 로 처리 예정) 외에 **병합을 막을 결함은 없다**. 1회차 K2(`MAM_JOB_TREE_MAX` 초과 시 대기 생략)·K3(`target_not_found` 의 rc 5)·K4(폴링마다 전체 스캔)는 이 델타 범위 밖이라 그대로 열려 있다.
| # | 비차단 관찰 | 근거 |
|---|---|---|
| L1 | 첫 단어 판정은 에이전트가 자기 사유를 `deadline_timeout`/`agent_blocked` 로 시작해 쓰면 미확인 종결로 본다 — 두 단어는 오케스트레이터 예약어라 실무 충돌은 없다 | `runner.py:696`, §2 표 |
## 6. 교차검증 가이드
```bash
RP=/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20260925T072328Z-2f5853/1
PY=/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.venv/bin/python
git -C "$RP" rev-parse HEAD 'HEAD^{tree}' # e80186f… / 3501868…
# in-process (env -u MAM_VERSION): 임시 등록부에 12가지 종결 사유를 .events.log / 감사 ndjson 으로 심고 _subjob_state 호출 (§2 표)
# 되돌린 사본: registry.py·runner.py 를 ed293dc 판으로 → -k "t13 or t14 or t6 or t5" → 4 failed / 3 passed
(cd "$RP" && AGENT_PYTHON_BIN="$PY" PYTHONDONTWRITEBYTECODE=1 "$PY" -m pytest -p no:cacheprovider -q --basetemp=/tmp/bt tests/test_issue34_job_tree.py) # 20 passed
```
[VERDICT: PASS]
@@ -0,0 +1,83 @@
# Issue #60 리뷰 2차 — opencode 2.x 전용 지원 (job 8a485fa0)
- 리뷰어: planner-reviewer-claude-01
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261018T064723Z-79133f/1`
- identity: HEAD `cb5f439cce41a2741a6b9974a308a765e50f00ad`, tree `cfec95403cbde10a6340b9007e1246c83be461cd` — 일치, 작업 트리 clean, `.mam` 없음. base..head 4커밋(a311c23, f0d1c00, 6b4b96f, cb5f439). 판정은 base..head 전체에 대해 내린다.
- 1차(6b4b96f)에서 확인한 사항(측정 사실↔구현, 실바이너리 `session delete` 의 자식 삭제·cwd 무관·공유 행 보존, `--standalone` 수명, 1.x 가정 잔존 없음)은 cb5f439 가 건드리지 않은 부분이라 그대로 유효하다. 이번에는 cb5f439 를 중심으로 봤다.
- 실제 opencode 는 `HOME`/`TMPDIR` 를 scratchpad 로, `XDG_*` 를 unset 하고 `api --standalone`·`session delete --standalone`·`debug paths` 로만 실행했다. 모델 호출 프롬프트는 보내지 않았다. 매 실험 뒤 남은 opencode 프로세스가 없음을 확인했다(사용자 서비스 pid 86524 제외). 실제 경로는 `ls`·`grep`(읽기)으로만 봤다.
## 결론
1차 지적 5건의 수정은 모두 동작하고, 테스트가 각각을 잡는다. 하지만 **N4 수정(XDG_DATA_HOME 따르기)이 테스트 격리를 깨는 새 회귀를 만들었다(B1).** 테스트 러너 환경에 `XDG_DATA_HOME` 이 있으면 sandbox 테스트가 sandbox 밖의 opencode 데이터 디렉터리(Linux 라면 사용자의 실제 DB)를 대상으로 삼고, 2개 테스트가 실패한다. 사용자가 다음에 Linux 호환성 시험을 할 예정이고 브리프가 실제 opencode 데이터 접근을 금지하므로 차단으로 본다. 수정은 fixture 두 줄이다(아래). 설계 변경은 필요 없다.
## B1 (차단). XDG_DATA_HOME 이 설정된 러너에서 sandbox 테스트가 sandbox 밖 DB 를 본다
원인: `opencode.py:87-94` `_data_home` 은 `ctx.home_dir` 이 `expanduser("~")` 와 같으면 호출자의 `XDG_DATA_HOME` 을 쓴다. 테스트 fixture `mam_sandbox`(`tests/conftest.py:259`)와 `home`(`tests/test_issue31_purge_residual.py:85-96`)는 `HOME` 을 tmp 로 바꾸지만 `XDG_DATA_HOME` 은 지우지 않는다(`conftest.py:307` 은 `XDG_CONFIG_HOME` 만 지움). 그래서 sandbox 의 HOME 이 "자기 홈" 으로 판정되어 러너의 `XDG_DATA_HOME/opencode/opencode.db` 가 선택된다. 1차 head 는 `_run` 이 `XDG_DATA_HOME=<home>/.local/share` 로 강제했으므로 이런 일이 없었다.
재현(검증 경로, `env -i … XDG_DATA_HOME=<scratchpad>/j8a/xdg`):
```
pytest tests/test_tier2_component.py tests/test_issue60_opencode_v2.py tests/test_a4_adapter_contract.py tests/test_issue31_purge_residual.py -k opencode
FAILED tests/test_tier2_component.py::test_comp_opencode_create_and_reconcile_and_stop
assert s_after.get("opencode_session_id_own") == parent → None (drift C 가 sandbox DB 가 아니라 XDG 쪽을 봄)
FAILED tests/test_issue31_purge_residual.py::test_opencode_purge_leaves_no_rows
'<sandbox>/home/.local/share/opencode/opencode.db (session_v2.id: 1 rows)' in [] (residual 이 XDG 쪽 DB 를 스캔)
2 failed, 21 passed
```
XDG 위치에 미리 DB 를 둔 대조 실험에서도 residual 스캔과 purge 의 DB 해석이 그 DB 를 가리켰다. 이 실험에서는 sandbox 세션 id 가 무작위라 지워진 행은 없었다. 그래도 Linux 에서 `XDG_DATA_HOME` 을 둔 사용자가 스위트를 돌리면:
- reconcile 은 실제 DB 를 읽고,
- purge 는 실제 DB 에 `DELETE … WHERE id=?` 트랜잭션과 가짜 `session delete` 를 실행할 수 있다.
- #55 가드(`tests/_agent_home_guard.py:48`)는 `$HOME/.local/share/opencode` 만 감시하므로 이것을 잡지 못한다.
수정 확인: 사본에서 `conftest.py` 의 `mam_sandbox` 에 `monkeypatch.delenv("XDG_DATA_HOME", raising=False)` 를, issue31 `home` fixture 에 같은 줄을 넣고 같은 조건으로 실행하자 **22 passed**, XDG 위치에 DB 접근 흔적이 없었다. 아래 N2 의 `--version` 로그만 남았다. `_agent_home_guard` 의 감시 대상에 `XDG_DATA_HOME` 쪽 opencode 디렉터리를 넣는 것도 권한다.
## 1차 지적 수정 확인
| 지적 | 수정 | 확인 |
|---|---|---|
| codex P2: 자식 세션의 1.x 행 잔존, 부모 residual 이 빔 | `_descendants`(`opencode.py:195-217`)가 삭제 **전에** session_v2·1.x session 의 `parent_id` 를 모든 깊이로 따라가고, `_LEGACY_TABLES` 8개를 부모+자손 id 로 정리한다. residual 은 자손을 `[child session <id>]` 로 보고한다 | **실바이너리 재현**: 임시 HOME 에서 `api --standalone` 으로 P→C→G(3단) + 무관 O 를 만들고, 1.x 형 `session(id,parent_id)`·message·part·todo·session_share 를 덧붙여 어댑터 purge 를 실행. `_descendants(P)==[C,G]`, purged `['opencode session (with its child sessions): P', 'sqlite rows for session: P and 2 child session(s)']`, residual P·C·G 모두 `[]`, O 는 7건 그대로. 순환 방지(`cid != uuid and cid not in found`), 읽기 전용 URI(`quote(abspath)`) 확인 |
| codex 주의: 사용자 서비스가 DB 를 잡아 모든 purge 가 exit 4 | `conversation_in_use`(`:240-262`)가 argv 검사 후 lsof 보유자 중 `serve --service` 가 아닌 것만 in-use. lsof/ps 예외는 in-use(fail-closed) | in-use CLI 시뮬레이션(임시 HOME, 가짜 opencode): 보유자 없음 → rc 0. `… serve --service` 보유자 → rc 0. `… serve --stdio --port 0` 보유자 → rc 4. argv 에 id → rc 4. 힌트에 lsof 줄 추가됨 |
| N4: XDG_DATA_HOME 무시 | `_data_home` | 제품 동작은 의도대로다(`test_the_data_dir_follows…`). 다만 B1, N1 |
| agy: 상주 자식 | `resident_children=('opencode',)` | `stall_watch.background_children`(`:118-136`)는 상주 자식을 건너뛰되 그 자식들은 계속 순회하므로, 서버 아래 도구 프로세스는 여전히 작업으로 잡힌다. 1차에서 실측한 서버 자식 이름(`opencode serve --stdio --port 0`, argv0 basename `opencode`)과 맞음 |
| claude N1·N2 | 실바이너리 테스트가 `_bin` 을 `REAL` 로 고정. tier2 가 더 늦은 자식 세션을 시드하고 부모 고정을 단언 | PATH 에 opencode 가 없는 `env -i` 로 `test_real_opencode_2x_contract` 통과(아래 테스트 결과) |
뮤테이션(git archive 사본, `-k "opencode and not real_opencode"`, issue60·issue31·tier2):
| 변형 | 실패 |
|---|---|
| 자손 제거(`ids=[uuid]`) | 1 (`test_purge_takes_the_1x_rows_of_the_child_sessions_too`) |
| residual 의 자손 보고 제거 | 2 |
| 서비스 제외 제거 | 1 (`test_the_background_service_alone…`) |
| XDG 무시 | 1 |
| resident `()` | 1 |
| reconcile drift C `parent_id IS NULL` 제거 | 1 (tier2 — 1차 N2 해소) |
| `_LEGACY_TABLES` 에서 todo·session_share 제거 | 2 |
## 테스트 실행 (검증 경로, `env -i`, PATH 에 opencode 없음, basetemp 는 scratchpad, `XDG_DATA_HOME` 미설정)
- 관련 12개 모듈(a4, issue31, issue10_13, issue60, tier1, tier2, issue5_phase0, issue23_24, issue6, c1, issue9, issue30_stall_watch): **473 passed, 1 skipped in 1010 s**, rc 0. skip 은 `/proc` 전용 테스트(macOS). 1차의 N1 이 해소되어 PATH 없이도 실바이너리 계약 테스트가 통과한다. #55 오탐 없음. 실행 뒤 남은 opencode 프로세스 없음.
- 같은 테스트를 `XDG_DATA_HOME=<scratchpad>` 로 실행하면 2 failed — B1.
## 비차단
**N1. XDG 를 따르게 되면서 status.sh 가 어긋난다.** `status.sh:108`, `:318` 은 여전히 `{home}/.local/share/opencode` 고정이다(reconcile 의 `:917` 폴백도 같으나 `_resolve_db` 가 항상 경로를 돌려줘 닿지 않음). Linux 에서 `XDG_DATA_HOME` 을 둔 사용자라면 reconcile 은 XDG 쪽 DB 에서 id 를 고정하는데 status 는 같은 세션을 `MISSING` 으로 보인다. 표시만의 문제지만 N4 수정과 같은 범위이므로 status 도 어댑터의 `_resolve_db` 를 쓰는 편이 낫다. `auth_ok`·create_session.sh 의 auth.json 검사는 브리프대로 의도적 비변경으로 보았다.
**N2. (기존, f0d1c00 부터) `test_issue60_opencode_v2.py` 를 import 하기만 해도 실제 opencode 가 실제 HOME 으로 실행된다.** `_real_opencode()`(`:~300`)의 `--version` 호출이 모듈 import 시점에 러너 환경 그대로 돈다. 그 결과 실제 `~/.local/share/opencode/log/opencode.log` 에 `args="[\"--version\"]"` 줄이 붙는다. 읽기로 확인: 2026-10-18T06:46~06:47Z 에 여러 줄. 내 1차 리뷰 실행도 같은 줄을 남겼음을 밝혀 둔다. 데이터 손상은 아니지만 "실제 ~/.local/share/opencode 쓰기 금지" 에 걸리므로, 버전 확인도 임시 HOME/XDG 로 돌리거나 테스트 본문(임시 HOME 설정 뒤)으로 옮기기를 권한다.
**N3. 서비스 제외의 한계(의도된 설계로 보임).**
- 다른 워크스페이스의 MAM opencode 세션(`--standalone` 서버)이 하나라도 떠 있으면, 그 세션과 무관한 purge 도 여전히 exit 4 다. 안전한 쪽의 오탐이다.
- 반대로, 사용자가 서비스 모드 TUI 나 웹 UI 로 MAM 세션을 id 없이 열고 있으면 그 사용은 보이지 않는다. MAM 은 `--standalone` 으로만 띄우므로 실사용 위험은 낮다.
- Linux 에 lsof 가 없으면 DB 보유자 검사가 통째로 빠진다. base 와 같다.
## Python 3.9 / bash / Linux
- 변경 파일은 Python 3.9 로 파싱된다(`/usr/bin/python3` 3.9.6, venv 3.9.6). f-string 안의 따옴표 중첩 없음, `from urllib.parse import quote` 는 표준.
- `ps -o command= -p` 는 procps-ng 에서도 지원된다.
- resident 테스트의 `exec -a opencode` 는 bash 기능이라 Linux 에서도 동작한다.
- 셸 스크립트는 cb5f439 에서 바뀌지 않았다.
- `git diff --check` 깨끗.
## 판정
B1 은 fixture 두 줄로 고쳐지고 수정 효과도 확인했다. 그러나 Linux 시험 직전에 사용자 실제 데이터 접근 가능성을 만드는 테스트 격리 회귀이므로 차단으로 둔다. 그 밖의 수정은 모두 PASS 수준이다.
[VERDICT: NOT PASS]
@@ -0,0 +1,14 @@
{
"loop_id": "loop-20261013T025132Z-60f239",
"base_sha": "ae2813773c973daedd2275cb55ad340a682c3fae",
"head_sha": "62da8056680fca939a2aff924037ed48b5f74afa",
"tree_sha": "f549246ffbe2d5e58cb7e33c212a383cd429dce5",
"review_commit": "62da8056680fca939a2aff924037ed48b5f74afa",
"reviewer": "planner-reviewer-claude-01",
"job_id": "8b2392ce",
"verdict": "PASS",
"transport": "herdr",
"evidence": "review-tree",
"isolation": "worktree",
"verified_at": "2026-10-13T02:56:58Z"
}
@@ -0,0 +1,22 @@
# README.md / README.ko.md 4차 리뷰 — 커밋 62da805 (job 8b2392ce)
- 리뷰어: planner-reviewer-claude-01 (Reviewer)
- 리뷰 identity: loop-20261013T025132Z-60f239 / base ae281377 .. head 62da8056 (커밋 1개, 두 README 각 2줄) / tree f549246f
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261013T025132Z-60f239/1` — HEAD·tree 일치, `git status --porcelain` 0 줄(시작·종료). 본 체크아웃 HEAD 2ccf439(main) 변동 없음.
- 방법: 바뀐 두 문장만 코드와 대조. `generate-env.sh`·설치기는 실행하지 않았고, 테스트도 돌리지 않았다(픽스처 집계는 `pytest --co` 에 수집 전용 플러그인을 얹어 `fixturenames` 만 셌다; `env -i`, basetemp 는 scratchpad). 실제 서버·세션·`.mam`·`.mam.env`·에이전트 홈 미접촉.
## 1. 두 문장의 코드 대조
| 문장 (README.md / README.ko.md 같은 줄) | 근거 | 판정 |
|---|---|---|
| **:41** `generate-env.sh` 는 `.mam.env` 가 없을 때 `.mam.env.example` 로 만들고, `--force` 는 `.mam.env.bak` 을 남기고 덮어씀 | `deploy/generate-env.sh:54-64`: `[ -f "$DST" ] && [ "$FORCE" != "1" ]` → "no-op … (use --force to overwrite)" `exit 0`; `[ -f "$DST" ] && [ "$FORCE" = "1" ]` → `cp -p "$DST" "$DST.bak"`; 그 뒤 `cp "$SRC" "$DST"`. 머리 주석(`:5-7`)과도 일치 | ✓ |
| **:175** 모든 테스트는 임시 잡 레지스트리·감사 로그; 약 3분의 1 이 임시 HOME 샌드박스 픽스처, 그보다 적은 수가 mock herdr | 이 head 에서 수집 1901 개 중 `fixturenames` 에 `mam_sandbox` 포함 **575 (30 %)**, `mock_herdr` 포함 **186 (9 %)**(`mock_agents` 125). grok 집계(1708 중 573/196)와 모수만 다르고 비율은 같다. "약 3분의 1" 과 "그보다 적은 수" 모두 맞음. 전체 테스트의 임시 레지스트리·로그는 `conftest.py:170-181` 의 autouse 픽스처(3차에서 확인) | ✓ |
## 2. 두 언어의 일치
- 두 문장 모두 영문·한국어가 같은 조건·같은 수치를 말한다(없을 때 생성 / `--force` 는 `.bak` 남기고 덮어씀; 약 3분의 1 / 그보다 적은 수).
- 구조(head): 제목 18/18, 표 행 17/17, 코드 블록 7/7(번역 주석 블록 외 동일), 숫자 집합 동일, 백틱 토큰 차이는 `MULTI_AGENT_RULES.md` ↔ `.ko.md` 뿐.
## 결론
grok 21063f28 의 두 지적이 코드와 맞게 고쳐졌고 두 언어가 같다. 앞 라운드에서 내가 남겼던 `generate-env.sh` 메모도 이 커밋으로 해소됐다. 남은 지적 없음.
[VERDICT: PASS]
@@ -0,0 +1,14 @@
{
"loop_id": "loop-20261003T230658Z-e7866a",
"base_sha": "8d26106195a0a24621f140e76389d206332f84c0",
"head_sha": "17eba0904ebbf54c1d2243ad61a6f2ba17263889",
"tree_sha": "a0f1ffede44be7c86267a56b25871e1d081feafb",
"review_commit": "17eba0904ebbf54c1d2243ad61a6f2ba17263889",
"reviewer": "planner-reviewer-claude-01",
"job_id": "95b63371",
"verdict": "PASS",
"transport": "herdr",
"evidence": "review-tree",
"isolation": "worktree",
"verified_at": "2026-10-03T23:35:18Z"
}
@@ -0,0 +1,56 @@
# 리뷰 리포트 — Job 95b63371 (PR #46 2회차 델타, 8d26106..17eba09)
- 리뷰어: planner-reviewer-claude-01 (claude)
- 리뷰 identity: loop-20261003T230658Z-e7866a / base 8d26106 / head 17eba09 / tree a0f1ffe — 검증 경로에서 `git rev-parse HEAD 'HEAD^{tree}'` 일치, 미커밋 변경 없음
- 범위: 커밋 17eba09 한 개. `git diff --stat 8d26106 17eba09 -- . ':!*.md' ':!tests'` 결과가 비어 있음 → 제품 코드·버전 변경 없음(항목 4 ✓)
## 결론 요약
codex R1~R3, claude O1·O4 가 모두 닫혔다. 차단 결함 없음. 비차단 관찰 2건(§5).
## 1. 지적 사항 종결 여부
| 지적 | 수정 | 확인 |
|---|---|---|
| codex R1 (attach 대상 서버) | resume SKILL.md Case 3·Workflow 설명·When NOT 이 `herdr --session <server> agent attach <name>` 로 서버를 명시, Verification 이 행에서 SERVER 를 읽어 `--session "$SERVER"` 사용 | ✓ 아래 §2 |
| codex R2 (--dry-run 문구) | create·resume 옵션 표: "no spawn and no registry (YAML/DB) update; 앞선 검사는 돌고 macOS 에서 quarantine 속성을 지운다" | ✓ create_session.sh:196 xattr → :280 dry-run 종료, spawn() 은 :264-274 정의만, YAML 쓰기 :305·:491 은 그 뒤. resume_session.sh:169 xattr → :225 dry-run 종료, spawn :237 |
| codex R3 (삭제 본문 복귀 가드) | test_no_deleted_body_came_back 이 DATA["blocks"] 를 읽지 않고 현재 모든 블록을 removed+superseded 의 base_sha / base_fingerprint 와 비교 | ✓ §3 탐침 |
| claude O1 (create:263-266 누락) | superseded 에 class c / invocation, base_sha ceb96a8c1293 으로 기록 | ✓ base 45d1621 에서 parse_blocks 로 다시 계산한 sha·fingerprint 일치 (removed 5 + superseded 1 모두 True/True) |
| claude O4 (표 문구) | create 42 에 `MAM_KILL_ON_READY_TIMEOUT=1` 경우, stop 0 에 "--purge-conversation 에서는 no-op 아님" | ✓ create_session.sh:388-398, stop_session.sh:185 와 일치 |
## 2. R1 — 안내 명령이 실제로 격리 서버로 가는가
herdr 서버를 건드리지 않도록 `--help` 의 Logs 경로(세션 해석 결과)만으로 확인했다(`env -i` 로 상속 변수 제거):
| 호출 | 해석된 세션 디렉터리 |
|---|---|
| `herdr --help` | ~/.config/herdr/ (기본 서버) |
| `herdr --session zz-probe --help` | ~/.config/herdr/sessions/zz-probe/ |
| `herdr --session default --help` | ~/.config/herdr/ (기본 서버와 동일) |
| `HERDR_SESSION=zz-probe herdr --help` | sessions/zz-probe/ |
| `HERDR_SESSION_NAME=zz-probe herdr --help` | ~/.config/herdr/ (무시됨) |
| `herdr --session "" --help` | `error: session name cannot be empty` |
(탐침 뒤 ~/.config/herdr/sessions 에 zz-probe·default 디렉터리 생성 없음 확인.)
- 따라서 `herdr --session <row.herdr_session> agent attach` 는 그 격리 서버로 간다. 행이 `herdr_session: default` 여도 `--session default` 가 기본 서버로 해석되므로 맞다. lib.sh shim(`_real_herdr`, lib.sh:255-268)이 default 일 때 `--session` 을 빼는 것과 결과가 같다.
- resume_session.sh 가 export 하는 것은 `HERDR_SESSION_NAME` 이고 native herdr 는 이를 읽지 않으므로(위 표), 서버 명시가 필요하다는 수정 근거가 맞다.
- Verification SERVER 계산: 행에 herdr_session 없음 → `'default'` → 기본 서버(올바름). 행 자체가 없음 → python IndexError, SERVER="" → herdr 가 "session name cannot be empty" 로 즉시 실패. 검증 블록으로서는 조용히 엉뚱한 서버를 보는 것보다 낫다. 행 기록자는 create_session.sh:524-525, update_yaml_resumed.sh:140-141·155-156 에서 herdr_session 과 herdr_server 를 항상 같이 쓰므로 herdr_session 만 읽어도 된다.
## 3. R3 가드 — 견고성과 엄격성(스크래치 사본 변이 시험)
| 변이 | 새 테스트(17eba09) | 이전 테스트(8d26106) |
|---|---|---|
| 기준 | 31 passed / 1 skipped | — |
| codex 탐침: base resume Workflow 본문(45d1621 :59-118)을 목록 항목 안에 2칸 들여 다시 넣고 인벤토리에 class c 로 등록 | **1 failed** (test_no_deleted_body_came_back) | 28 passed (못 잡음) |
| 정상 편집: create 에 새로운(다른) 호출 예시 추가 | R3 가드 통과, 인벤토리 테스트만 실패(의도된 동작) | — |
| 삭제 본문 + 주석 한 줄을 앞에 붙여 다시 넣음 | R3 가드 통과(인벤토리 테스트만 실패) | — |
- fingerprint(공백 접기 후 sha256 12자)는 재들여쓰기·줄 바꿈 변화에 강하고, 정확 일치만 보므로 다른 예시를 막지 않는다. 정상 편집을 막을 정도로 엄격하지 않다.
- test_a_returned_body_is_caught_reindented_and_with_the_inventory_updated 는 가드 함수 자체를 현재 stop 블록으로 검사한다(정확 sha + 재들여쓰기 fingerprint) — 유효.
## 4. 인벤토리 재생성
8d26106 과 17eba09 의 skill_code_blocks.json 비교: blocks 33개의 (skill, class, kind) 순서가 **동일**. 바뀐 값은 resume Verification 블록(start 103)의 sha·end(124→128) 뿐이고, removed 5개는 base_fingerprint 추가만, entry_points 동일, superseded 신설. 분류 변경 없음(항목 3 ✓).
## 5. 비차단 관찰
- **N1 가드의 한계**: R3 가드는 블록 본문 전체 일치만 본다. 삭제된 본문 앞에 한 줄만 붙여도(§3 셋째 행) 인벤토리를 갱신하면 통과한다. 이번 지적(R3)의 범위인 "인벤토리 재등록으로 숨기기"는 막았으므로 차단은 아니며, 필요하면 후속에서 줄 단위 포함 비율로 넓힐 수 있다.
- **N2 문구 정밀도**: resume SKILL.md Workflow 설명의 "the native `herdr` binary does not route on environment variables" 는 정확히는 "`HERDR_SESSION_NAME` 으로는 라우팅하지 않는다"이다 — native herdr 는 `HERDR_SESSION` 을 읽는다(§2 표). 특히 herdr 패널 안에서는 `HERDR_SESSION` 이 호출자 서버로 설정되어 있어, 서버를 빼면 호출자 서버로 간다. 결론(`--session` 명시)은 그대로 옳다.
- 미반영으로 명시된 codex C2, claude O2·O3 는 후속 범위로 동의한다.
## 6. 테스트
검증 경로에서 지정 6개 파일: **132 passed / 0 failed / 1 skipped** (300 s, rc 0). skip 은 test_claude_skills_link_resolves_to_agents_skills(워크트리, 정상). test_issue42_skill_blocks.py 단독 31 passed / 1 skipped. MQTT_* unset, .mam.env 미export. 실제 .mam/jobs 항목 수 변화 +0.
[VERDICT: PASS]
@@ -0,0 +1,14 @@
{
"loop_id": "loop-20261005T085016Z-c0f6eb",
"base_sha": "e8d9151226d8139c7b5688e83d035a3a747aeb2d",
"head_sha": "ab93776d6d1b8ca534c14b6be53f61dc3a0fa2a9",
"tree_sha": "f49ffdc8e95dc316f752fd81b2d55a0efddf1a12",
"review_commit": "ab93776d6d1b8ca534c14b6be53f61dc3a0fa2a9",
"reviewer": "planner-reviewer-claude-01",
"job_id": "ab18016d",
"verdict": "PASS",
"transport": "herdr",
"evidence": "review-tree",
"isolation": "worktree",
"verified_at": "2026-10-05T09:05:24Z"
}
@@ -0,0 +1,50 @@
# 리뷰 리포트 — Job ab18016d (PR #48 3회차 델타, e8d9151..ab93776)
- 리뷰어: planner-reviewer-claude-01 (claude)
- 리뷰 identity: loop-20261005T085016Z-c0f6eb / base e8d9151 / head ab93776 / tree f49ffdc — 검증 경로에서 `git rev-parse HEAD 'HEAD^{tree}'` 일치, 미커밋 변경 없음
- 범위(항목 4): lib_py/herdr_config.py(+25/−2), lib_py/herdr_restore.py(+10/−2), 테스트 2개. bash·문서·버전 변경 없음. 새 판정은 모두 Python 에 있다(#17).
## 결론 요약
codex R1~R3 가 닫혔다. R1 의 "MINIMAL 무검사 사용" 판단에 동의한다. `_same_doc` 은 nan 외에는 이전 `==` 와 같거나 더 엄격하며, 그 엄격함이 오탐(불필요한 MINIMAL 폴백)을 만들지 않음을 확인했다. 차단 결함 없음.
## 1. R1~R3 종결
| 지적 (codex 2회차) | 수정 | 확인 |
|---|---|---|
| R1 `config check` 를 실행 못 하면(timeout·OSError) 병합 결과를 검사된 것으로 넘김 | herdr_config.py:245-258 — 병합 본문이면 경고 후 `continue` 로 MINIMAL 시도, MINIMAL 의 검사도 실행 못 하면 MINIMAL 을 경고와 함께 사용 | test_an_unrunnable_check_never_hands_over_an_unchecked_merge[timeout/oserror]: 스키마 오류가 든 사용자 설정이 생성 파일에 들어가지 않고 MINIMAL + "not using an unchecked merge" 경고, 원본 불변. 이전 제품 코드에서 2건 실패 |
| R2 `_shell_in_foreground` 가 0/0·False/False·""/""·-1/-1 에서 True | herdr_restore.py:44-53 — 두 값이 bool 아닌 양의 int 일 때만 비교 | test_issue45_restore_unit.py: zero·false·empty·negative·null·string·true·float 모두 닫지 않음, 정상 pid 같음 → 닫음, 다름 → 둠. 이전 코드에서 7건 실패(null 은 이전에도 통과) |
| R3 재파싱 `==` 가 nan≠nan 이라 nan 하나로 사용자 설정 전체를 버림 | herdr_config.py:140-150 `_same_doc`, :171 에서 사용 | test_a_nan_value_does_not_drop_the_other_settings[top/array/nested]: 경고 없이 설정 유지. 이전 코드에서 3건 실패 |
이전 제품 코드(e8d9151 의 두 파일)로 되돌린 스크래치 사본에서 새 테스트는 **12 failed / 55 passed**(3.11), 현재 코드에서 **67 passed**.
## 2. R1 판단 — MINIMAL 무검사 사용 vs 경로 반환 안 함
동의한다. 근거:
- 경로를 반환하지 않으면 lib.sh:241-245 가 `HERDR_CONFIG_PATH` 없이 서버를 띄운다 → herdr 는 사용자 설정(복원 기본 켜짐)을 읽어 **#45 가 확실히 재발**한다.
- MINIMAL 을 넘기면, herdr 가 그것을 받아들이는 경우(고정 2줄, 실제 herdr 0.9.1 에서 test_real_herdr_accepts_every_result 가 확인) 복원이 꺼지고, 만약 거부하더라도 herdr 는 기본값으로 돌아 결과는 "경로 없음"과 같은 복원 켜짐이다. 즉 MINIMAL 은 복원 측면에서 "경로 없음"보다 나쁜 경우가 없고, 잃는 것은 이미 경고되는 사용자 설정뿐이다.
- 검사가 **실행되어 거부**한 경우는 여전히 None(:259-262)이다. "검사 불가"와 "검사 거부"를 다르게 다루는 것이 일관적이다(거부는 herdr 가 그 파일을 버린다는 증거).
- 알 수 없는 nonzero 종료를 통과로 두는 것(:178)도 기존 근거(무해한 경고에도 nonzero)와 같고 이번 변경 범위 밖이다.
## 3. `_same_doc` 의 의미 (항목 3)
3.11 에서 이전 `==` 와 직접 비교:
| 경우 | 이전 == | _same_doc |
|---|---|---|
| nan / nan, 중첩 nan | False | **True** (의도된 변경) |
| 1 / 1.0, True / 1, 0 / False | True | **False** (더 엄격) |
| -0.0 / 0.0, inf / inf, 같은 time, 같은 문자열 | True | True |
| date / datetime | False | False |
| [1,2] / [2,1] (배열 순서) | False | False |
| dict 키 순서만 다름, 표 배열 [{…}] | True | True |
- 더 엄격해진 int/float/bool 구분은 직렬화가 타입을 보존하는 한 오탐을 만들지 않는다: dumps(:107-126)는 int→정수, float→repr(소수점·지수 유지), bool→true/false 로 쓴다. 날짜·오프셋 datetime·소수 초 local time·표 배열·inf·nan·−nan·따옴표 점 키·1e100 이 든 문서로 server_config_text 를 돌려 **설정 유지, 경고 없음**을 확인했다. 오히려 왕복에서 1 이 1.0 으로 바뀌는 것 같은 결함은 이제 잡힌다.
- 참고: `-nan` 은 `nan` 으로 쓰여 부호가 사라진다(nan 끼리 같다고 보므로 통과). herdr 설정에서 nan 부호가 의미를 가질 일은 없어 무해하다.
## 4. 테스트 실행
| 실행 | 결과 |
|---|---|
| 지정 4개 파일 (pr-48 .venv, Python 3.9, 실제 herdr 포함) | **53 passed / 34 skipped / 0 failed** (76 s). skip = tomllib 전용; test_issue45_restore 는 skip 0 |
| herdr_config + restore_unit (지정 venv311, Python 3.11.15) | **67 passed** |
| 같은 테스트, 이전 제품 코드 사본 | 12 failed / 55 passed |
격리: 실행 전후 herdr 프로세스는 기존 multi-agent-mux 서버·attach·오케스트레이터 `agent wait` 뿐, `/private/tmp/m45*` 없음, `~/.config/herdr` 목록·config.toml md5·sessions 불변. MQTT_* unset, .mam.env 미export.
## 5. 비차단 관찰
- R1 테스트는 두 검사 모두 실행 불가인 경우만 다룬다. "병합 검사는 실행 불가, MINIMAL 검사는 실행되어 통과" 경로(:256 → 다음 반복의 :258)는 코드상 자명하지만 별도 테스트는 없다.
- 2회차의 VERSIONS 4.8.5 항목 1 문장 순서 지적(N1)은 이번 델타에 포함되지 않았다(문서 변경 없음).
[VERDICT: PASS]
@@ -0,0 +1,176 @@
# 🔍 Review Report — PR #35 병합 전 독립 리뷰 (job `adc0ff3c`)
- **Reviewer**: `planner-reviewer-claude-01` (role: planner,reviewer) · 2026-09-23
- **대상**: PR #35 `fix/issue-23-24` **@ `f4ba205`**, base `main 1e78dae` (v4.7.1). 13커밋, 22파일 `+2197/−118`, `git diff --check` 클린.
- **검증 경로**: `/var/folders/.../T/mam-review/loop-20260923T142448Z-609954/1`. **identity 확인**: `HEAD = f4ba205c…fd2f`, `HEAD^{tree} = 88cb4f34…f181` — 브리프의 `head`·`tree` 와 일치.
- **안전 규칙 준수**: 검증 경로·소스 워크트리(`multi-agent-mux-wt/pr-35`)·본 체크아웃(`main 1e78dae`) 모두 수정·커밋·checkout 없음. 실험은 scratchpad 임시 디렉터리(uuid4 이름)에서만, pytest 는 `AGENT_PYTHON_BIN=<제어 루트 .venv>` + `--basetemp=<검증 경로 밖>` 으로 실행. 실제 herdr 서버·에이전트 세션·`.mam`·데몬 조작 없음(이 pane 의 herdr 환경변수를 **읽기만** 했다), push·merge·PR 상태 변경 없음, purge·scrub 실행 없음.
## 결론 요약
**병합을 막을 결함은 없다.** 세 핵심 규칙 — 교차 서버 판정, "이번 dispatch 의 started 만 증거", kill 결과 확인 롤백 — 을 각각 독립 실험으로 재현·확인했고, 새 테스트는 실제 `registry.py`/`publish_event.py` 를 쓰며 홈·브로커·실제 herdr 에 닿지 않는다. 비차단 지적 6건(F1–F6)을 남긴다. 그중 F1 은 **브리프의 codex 수락 키 서술과 코드가 다르다**는 점이라 오케스트레이터가 확인할 필요가 있다.
## 1. #24 시작 다이얼로그 (항목 1)
### 1-1. 어댑터 시그니처·수락 키
`python -m lib_py.agents facts <agent>` 가 실제로 내놓는 값(검증 경로에서 실행):
| 에이전트 | `MAM_STARTUP_DIALOGS` | 강한 준비 토큰 |
|---|---|---|
| claude | 기존 사다리 5개(trust Enter / `Yes, try it` Escape / `Yes, proceed` Down+Enter / `Resuming…` Enter / `Press Enter…` Enter)가 그대로 옮겨짐 | `Anthropic\|Assistant\|…\|Haiku` |
| agy | `Do you trust the contents of this project\|Yes, I trust this folder` → `Enter`, trust | `Antigravity` |
| grok | `Do you trust the contents of this directory` → `y`, trust | `Grok\|xAI\|Assistant\|❯\|>>>` |
| codex | `Do you trust…\|1\. Yes, continue` → **`Enter`**, trust · `1\. Update now\|Skip until next version` → **`2`**, notice | `Codex\|Ask Codex to do anything` |
| opencode·hermes | `[]` (다이얼로그 없음) | 있음 → 핸들러가 배너에서 즉시 반환 |
**F1 (확인 요망, 비차단)**: 브리프는 codex 수락 키를 "`1`+Enter, 업데이트 안내 `3`+Enter" 로 적었으나 코드는 **Enter 단독**(1번 항목이 미리 선택돼 있다는 주석)과 **`2`**("2 = Skip" 이라는 관찰 기록, 2026-09-22) 다. 어느 쪽이 실제 화면인지는 이 리뷰에서 재현할 수 없다(실제 codex 를 띄우는 것은 금지). PR 본문은 codex 를 포함한 4개 에이전트의 실제 herdr 시험이 사람 개입 없이 `started`(토큰 포함)→`completed` 까지 갔다고 기록하므로 **코드 쪽이 실측**으로 보이지만, 브리프 작성자의 관찰과 다르므로 병합 전에 한 번 맞춰 볼 것을 권한다. `_MAM_READY_TOKENS_CLAUDE` 상수는 제거되고 어댑터의 `strong_ready_tokens` 로 대체됐다(값 동일).
### 1-2. 준비 토큰으로 수락을 판정하는 이유
`handle_startup_dialogs`(`lib.sh:2979-`)는 시그니처가 화면에 남아 있어도 같은 인덱스에 대해 `MAM_DIALOG_RESEND_S`(기본 5 s) 안에는 재전송하지 않고(`_mam_dialog_pick` 의 cooldown), 반환 조건은 **오직 준비 토큰**이다. `--source visible` 스냅샷이 수락된 다이얼로그를 한동안 계속 보여 준다는 #24 의 관찰과 맞물려 타당하다 — 옛 코드는 매 폴링마다 Enter 를 다시 보내 이미 넘어간 TUI 에 입력을 흘렸다. `test_c1_tui_readiness.py::test_c1b` 의 단언이 `==30` 에서 `1 ≤ n ≤ 30` 으로 바뀐 것도 같은 이유이며, "한 프레임에 두 번 보내지 않는다"는 원래 가드는 그대로다.
### 1-3. `--no-auto-trust` / `MAM_AUTO_TRUST=0`
`_mam_dialog_pick` 은 `kind == "trust"` 이고 auto_trust 가 꺼져 있으면 `skip|idx|` 를 내고, 핸들러는 경고만 남긴다(cooldown 마다 1회). `notice` 는 항상 답한다. create(`create_session.sh:86-94`)와 resume(`resume_session.sh:43-57`)이 **같은 스위치·같은 env 변수(`MAM_AUTO_TRUST`)·같은 호출(`handle_startup_dialogs "$SESSION" N "$AGENT"`)** 을 쓴다. S6/R3 가 고정.
### 1-4. 자동 수락의 적용 범위(보안)
pane 은 `new-session -c "$WORKSPACE"` 로 뜨고 신뢰 창은 그 cwd 에 대한 것이므로 실질적으로 `--workspace` 에만 적용된다. 다만 시그니처 정규식은 **디렉터리 이름을 검사하지 않는다** — 화면에 어떤 경로가 적혀 있든 매치되면 수락한다(F5). 시작 직후의 신뢰 창이 다른 디렉터리를 가리킬 경로는 없어 실무 위험은 낮다.
## 2. #23 교차 서버 스코프 (항목 2)
`_herdr_caller_server` / `_herdr_call_is_cross_server`(`lib.sh:1403-1447`)를 lib.sh 에서 함수만 추출해 `env -i` 로 9가지 조합을 돌렸다(전부 기대와 일치):
| `HERDR_SOCKET_PATH` | `HERDR_SESSION` | 대상 | 판정 |
|---|---|---|---|
| `~/.config/herdr/herdr.sock` | – | `mam` | caller=`default`, **CROSS** (보고된 #23 시나리오) |
| 〃 | – | (없음) / `default` | same (앰비언트 sentinel) |
| `~/.config/herdr/sessions/mam/herdr.sock` | – | `mam` | caller=`mam`, same → **pane 배치·workspace 패킹 유지** |
| 〃 | – | `other` | CROSS |
| 〃 | `orch` | `mam` | caller=`orch`(HERDR_SESSION 우선), CROSS |
| `/other/layout/herdr.sock` / 비어 있음 | – | `mam` | caller="" → same (pane 밖: 명시 스코프 존중) |
| `…/sessions/a b/herdr.sock` | – | `mam` | caller=`a b`, CROSS (공백 안전) |
- 제거는 `mam_herdr` 안에서 **교차일 때만** `env -u HERDR_WORKSPACE_ID -u HERDR_PANE_ID -u HERDR_TAB_ID` 로 이루어지고(`lib.sh:1449-1461`), argv 배열 + `if` 로 판정하므로 `set -e` 호출자를 중단시키지 않는다(S12).
- `lib_py/loop/watcher.py::caller_server/scoped_env` 는 같은 규칙의 파이썬 쌍둥이다. 같은 5가지 입력으로 돌려 bash 와 **동일 결과**를 확인했다(같은 서버·pane 밖·`default`·`None` 대상은 ids 유지, 교차만 제거).
- **실제 레이아웃 근거**: 이 pane(herdr **0.9.1**)의 환경은 `HERDR_SOCKET_PATH=~/.config/herdr/sessions/multi-agent-mux/herdr.sock`, `HERDR_SESSION=multi-agent-mux` 였다 — 규칙이 전제하는 `sessions/<name>/herdr.sock` 형태와 일치하고, `HERDR_SESSION` 도 export 된다. 0.8.2 의 "기본 서버 pane 에는 `HERDR_SESSION` 이 없다"는 전제는 PR 의 실측 기록(코드 주석)에 의존하며 여기서 재현할 수 없다. 두 경우 모두 소켓 경로 규칙이 뒷받침하므로 어느 버전이든 판정이 성립한다.
- 인식되지 않는 레이아웃은 ""(= pane 밖) 으로 떨어져 **오늘의 동작을 유지**한다. 잘못된 제거보다 안전한 쪽이다.
## 3. #23 온보딩 전달 판정 (항목 3)
### 3-1. 공유
`delegate_publish_cmd`·`delegate_job_status`·`delegate_started_recorded`·`delegate_wait_started` 가 `lib.sh:2189-2313` 에 하나씩 있고, create 온보딩(`create_session.sh:622-680`)과 래퍼 `_wait_started`(`multi-agent-mux-delegate-job:647-668`)가 **같은 `delegate_started_recorded`** 를 부른다. 래퍼는 `MAM_JOBS_DIR="$REGISTRY_DIR"` 와 `LOGS_DIR_ABS` 를 넘겨 자기 레지스트리·감사 루트를 그대로 쓴다(`:191`,`:659`). 옛 `_disk_status != pending → 성공` 우회와 구독자 콘솔 grep 은 **삭제**됐다(diff 로 확인).
### 3-2. 증거 규칙 실측
`delegate_started_recorded` 만 추출해 가짜 `events.log`/`events.ndjson` 으로 10가지를 돌렸다(전부 기대와 일치):
| # | 상태 | 토큰 요구 | 결과 |
|---|---|---|---|
| 1 | 파일 없음 | 있음 | NOT |
| 2 | 이전 dispatch 토큰의 `started` | 있음 | **NOT** (R1: 이전 시도의 증거 거부) |
| 3 | 〃 | 없음 | recorded (토큰 없으면 종전대로) |
| 4 | 이번 토큰의 `started` | 있음 | recorded |
| 5 | 감사 기록 `dispatch_verified:true`, **fingerprint 없음** | 있음 | **NOT** (신원 없는 과거 기록 fail closed) |
| 6 | 〃 | 없음 | recorded |
| 7 | 이전 시도의 fingerprint | 있음 | NOT |
| 8 | 이번 토큰의 fingerprint | 있음 | recorded |
| 9 | fingerprint 맞지만 `dispatch_verified:false` | 있음 | NOT |
| 10 | 깨진 줄 + `progress` 만 | 있음 | NOT (JSON 파싱, 패턴 매칭 아님) |
fingerprint 는 `sha256(token)` 으로 구독자(`job_subscriber.py:303-306`)와 **같은 해시**임을 확인했다. 레지스트리 `status` 는 어느 경로에서도 증거로 쓰이지 않는다.
### 3-3. 종료된 잡·#18 정합
- create 는 더 이상 에이전트 대신 `started` 를 발행하지 않는다(`:649-657` 주석과 코드; S7). 미확인 시 `delegate_job_status` 가 `completed|error|cancelled` 면 **덮어쓰지 않고** 경고만 한다(`:674-679`; S7m).
- 대기 시간은 `MAM_ONBOARD_ACK_S` → `MAM_INJECT_ACK_GRACE_S` → 잡의 `ack_timeout_sec`(기본 180) 순이다 — #18 의 T=180 과 같은 값이고, heartbeat 계약은 `started --heartbeat 30` 명령을 `delegate_publish_cmd` 가 래퍼와 **인자 단위로 동일**하게 만든다(S7l).
- `inject_instructions`(= `send_keys_safe`) 의 rc 4 는 세 자리 모두 "전달 미상"(`lib.sh:2746` busy 초과, `:2810` delivery unknown, `:2883` Enter 미수락)이라 `started` 대기로 판정을 넘기는 것이 맞고, rc 1(quiesce 실패)·2·3(다이얼로그 차단)은 즉시 실패로 남는다(S8/S8b).
## 4. #23 실패 처리 (항목 4)
- `cleanup_herdr_on_error`(`create_session.sh:318-346`): `kill-session` 의 rc 를 버리지 않고 받은 뒤 `has-session` 으로 재확인, 살아 있으면 행을 `running` + `last_visible_status: rollback-failed` + `delegate_job_id: null` 로 표시하고 **exit 43**. bash 3.2 에서 EXIT trap 안의 `exit 43` 이 원래 종료 코드(1)를 덮어씀을 직접 확인했다(`rc=43`). 상태값은 `atomic_yaml.py:18` 의 어휘(`running/terminated/archived/stopped`) 안이고 `last_visible_status` 는 reconcile 이 이미 쓰는 필드다.
- 온보딩 미확인: 세션 보존 + 잡 `error`(이미 종료된 잡은 제외) + 경고 2줄, `ROW_APPENDED` 뒤라 YAML 행은 유효한 `running` 이다. 실패한 잡은 `JOB_TERMINALIZED=1` 로 표시돼 트랩이 `cancelled` 를 겹쳐 발행하지 않는다.
- create SKILL.md(`:101-131`)가 이 규칙(started 기반 판정, rc 4 경고, 세션 보존, `MAM_ONBOARD_ACK_S`, exit 42/43 과 행 상태)을 정확히 서술한다.
## 5. 기존 테스트 조정 6파일 (항목 5)
| 파일 | 변경 | 판정 |
|---|---|---|
| `conftest.py` | `mam_sandbox` 에 `MAM_ONBOARD_ACK_S=3` 추가 | 타당. 샌드박스에는 `started` 를 발행할 실제 에이전트가 없어 이것이 없으면 create 테스트마다 180 s 를 소모한다. create 전용 변수라 래퍼의 `MAM_INJECT_ACK_GRACE_S` 를 단언하는 테스트와 간섭하지 않는다 |
| `test_b19_*` | `grep -q 'Yes, try it'` 가 lib.sh 에 있는지 → claude 어댑터의 `('Yes, try it', ['Escape'], 'notice')` 로 위치 이동 | 토큰 목록 두 단언은 그대로. 검증 대상이 이사한 것뿐 |
| `test_c1_tui_readiness.py` | `KEYS_COUNT==30` → `1 ≤ n ≤ 30`, `custom` 에이전트 → `claude` | §1-2 참조. cooldown 도입으로 "매 반복 1회" 가 "프레임당 최대 1회" 로 바뀌었고 원래 가드(2회 금지)는 유지·강화. `custom` 은 어댑터가 없어 다이얼로그 목록이 비므로 이 시험에는 `claude` 가 맞다 |
| `test_issue14_delivery_ack.py` | 레지스트리 상태만 `running` 으로 flip 하던 "ack" 를 **실제 `publish_event.py` + dispatch 토큰의 `started`** 로 교체 | **강화**. 옛 시험은 PR 이 없앤 "상태만 보고 성공" 우회를 통과 조건으로 쓰고 있었다. 새 시험은 로컬 `events.log` 에 토큰이 실린 줄까지 단언한다 |
| `test_issue18_liveness_contract.py` | acceptance5 의 배달 단언을 `recorded 'started'` 하나로 좁힘 | 계약상 옳다(레지스트리 상태는 증거가 아님). 다만 "브로커가 retained `completed` 만 배달" 한 경우가 이제 **실패**한다 — `_skip_if_broker_did_not_deliver` 는 그 잡의 어떤 이벤트도 못 받았을 때만 skip 하므로. 공개 브로커에서 flake 가 늘 수 있는 자리(F4) |
| `test_issue5_phase2a_loop.py` | G9 가드가 코드만 읽도록(`ast` 로 docstring **노드**만 제거, 주석은 unparse 에서 사라짐) + `g9b` 자체 검증 | 약화 아님. 호출 형태(`subprocess` argv 의 `lib.sh` 경로·`source`, 식별자)는 여전히 코드 텍스트에 있어 잡히고, `g9b` 가 "같은 줄의 코드"(R2) 네 가지까지 고정한다. `ast.unparse` 는 3.9+ — venv 3.9.6 이라 통과 |
## 7. 문서 (항목 7)
- create SKILL.md `:101-131`: started 기반 판정·rc 4 경고·세션 보존·`MAM_ONBOARD_ACK_S`·`--no-auto-trust`(codex 의 로컬 설정 로드 위험 명시)·`MAM_DIALOG_RESEND_S`·exit 42/43 과 행 상태가 코드와 일치한다. resume SKILL.md 는 에이전트 분기 `case` 를 없애고 같은 호출로 바꿨다.
- **F2 (비차단, 문서 누락)**: 감사 기록의 새 필드 `dispatch_verified`·`dispatch_fingerprint`·`attempt_id` 는 `MESSAGING.md`·delegate-job `SKILL.md` **어디에도 없다**(전수 grep 0건). 두 문서는 `events.ndjson` 을 단계 수준으로만 서술해 필드 목록 자체가 없으므로 모순은 없지만, "래퍼와 create 가 무엇을 증거로 읽는가"가 이번 PR 의 핵심이라 한 문단은 있어야 한다.
- **F3 (비차단, 문구 정밀도)**: SKILL.md 는 `MAM_ONBOARD_ACK_S` 가 래퍼의 `MAM_INJECT_ACK_GRACE_S` 와 "별개" 라고만 적었지만 코드의 우선순위는 `MAM_ONBOARD_ACK_S` → `MAM_INJECT_ACK_GRACE_S` → 잡의 `ack_timeout_sec` 이다(`create_session.sh:658`). 즉 래퍼 변수도 create 의 대기에 **2순위로 영향**을 준다.
- `.claude/skills`: 본 체크아웃의 것은 git 에 추적되지 않는 로컬 심링크(`../../.agents/skills/*`, 10개)라 자동 동기화된다. 검증 경로(워크트리)에는 존재하지 않는다 — 배포 사본 정합 문제 없음.
## 6. 테스트 (항목 6)
### 6-1. 검증 경로에서의 실행
```
cd <검증 경로> && AGENT_PYTHON_BIN=<제어 루트 .venv/bin/python> PYTHONDONTWRITEBYTECODE=1 <같은 인터프리터> -m pytest \
-p no:cacheprovider -q -rfEs --basetemp=<검증 경로 밖> \
tests/test_issue23_24_startup_path.py tests/test_issue24_resume_dialogs.py tests/test_b19_headless_reconcile_fixes.py \
tests/test_c1_tui_readiness.py tests/test_issue14_delivery_ack.py tests/test_issue18_liveness_contract.py \
tests/test_issue5_phase2a_loop.py tests/test_herdr_shim_contract.py tests/test_loop_cli.py
→ 273 passed in 700.58s (0:11:40) failed 0 · errors 0 · skipped 0
```
새 파일 2개(73건)와 조정된 6파일, 그리고 셸 shim 계약·loop CLI 까지 모두 통과. 브로커 의존 시험(issue14·issue18)도 이번 실행에서는 flake 없이 통과했다.
### 6-2. 수정 없이는 실패하는가 — 확인함
검증 경로 사본에서 PR 이 바꾼 소스 12파일(lib.sh·어댑터 5·`__main__.py`·`job_subscriber.py`·`watcher.py`·create/resume 스크립트·래퍼)만 base `1e78dae` 판으로 되돌리고 새 테스트 2파일을 돌렸다:
```
60 failed, 12 passed, 1 skipped in 275.50s
```
실패 60건은 S1(교차 서버)·S4~S6(다이얼로그)·S7/W(started 증거)·S8(rc 4)·S9/S10(롤백)·R1~R4(resume) 전 영역에 걸친다. 통과한 12건은 "같은 서버는 스코프 유지"(S2/S2b)·"shim 은 바뀌지 않음"(S11)·"호출자가 준 토큰은 덮어쓰지 않음"(S5e) 같은 **불변 조건**들이라 수정과 무관한 것이 맞다. 즉 새 테스트는 이 변경을 정확히 겨냥한다.
### 6-3. 격리·픽스처·시간
| 항목 | 결과 |
|---|---|
| 실제 `.mam/jobs` / `.mam/delegate_job_logs` 전후 | **차이 0 / 차이 0** |
| 실제 herdr 서버·홈 | `mam_sandbox` 가 `HOME`/`HOME_DIR` 를 `tmp_path` 로 바꾸고(`conftest.py:210-211`) herdr 는 mock. 새 파일에서 `hivemq`·실제 소켓 참조 0건. 이 pane 의 herdr 환경변수는 S1d 가 **읽기만** 한다 |
| 실제 생산자 사용 | S7i~S7n·W 계열은 `REGISTRY_PY`/`PUBLISH_PY`(실제 `registry.py`·`publish_event.py`)로 잡을 등록하고 `started` 를 발행한다(`test_issue23_24_startup_path.py:620-660`). "손으로 만든 픽스처가 결함을 가렸다"는 PR 의 교훈이 테스트에 반영돼 있다 |
| 검증 경로 | tracked 파일 변화 없음(HEAD `f4ba205` 유지). 내 실행이 무시되는 `.mam/` 을 만들었다(#32 리뷰 때와 같은 현상, 그대로 둠) |
| 실행 시간 | 새 파일 2개는 대체로 빠르지만 **`test_issue24_resume_dialogs.py` 4건이 각 39~43 s** 다(resume 의 `handle_startup_dialogs … 20` 타임아웃과 mock 준비 대기가 겹친다). 스위트 전체 시간에 +2.7 분을 더한다(F6) |
## 8. 병합 차단 여부 (항목 8)
버전 락스텝 bump 부재(`lib.sh:32` `4.7.1`, 오케스트레이터가 4.7.2 로 처리 예정) 외에 **병합을 막을 결함은 없다**.
| # | 비차단 지적 | 근거 / 권고 |
|---|---|---|
| F1 | **codex 수락 키가 브리프 서술과 다르다.** 코드: 신뢰 창 `Enter`(1번 미리 선택), 업데이트 안내 **`2`**(Skip, 이번만). 브리프: `1`+Enter, `3`+Enter(Skip until next version). S4 의 codex 화면 픽스처(`Update available / 1. Update now / 2. Skip / 3. Skip until next version`)를 보면 3개 항목이 실제로 있고, 코드는 그중 **2** 를 고른다 — "아무것도 바꾸지 않는" 선택이라는 주석과 일치하나 다음 시작 때 다시 뜬다. 실제 시험은 PR 본문대로 통과했으므로 동작은 성립 | 병합 전 브리프 작성자와 한 번 맞출 것; `3` 이 의도라면 어댑터 한 줄 |
| F2 | 감사 기록의 새 필드(`dispatch_verified`·`dispatch_fingerprint`·`attempt_id`)가 MESSAGING.md·delegate-job SKILL.md 에 없다 | 한 문단 추가 권고 |
| F3 | `MAM_INJECT_ACK_GRACE_S` 가 create 의 대기에도 2순위로 영향을 준다는 점이 SKILL.md 에 없다(`create_session.sh:658`) | 문구 보강 |
| F4 | acceptance5 가 "retained `completed` 만 배달" 된 경우를 이제 실패로 본다(계약상 옳음). 공개 브로커에서 flake 가 늘 수 있는 자리 | 관찰되면 skip 조건 재검토 |
| F5 | 다이얼로그 시그니처는 화면의 디렉터리 경로를 검사하지 않는다 — pane 이 `-c $WORKSPACE` 로 뜨므로 실무 위험은 낮다 | 정보 |
| F6 | `test_issue24_resume_dialogs.py` 4건이 각 ~40 s | 타임아웃을 env 로 줄일 수 있으면 개선 |
## 9. 교차검증 가이드
```bash
RP=/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20260923T142448Z-609954/1
PY=/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.venv/bin/python
git -C "$RP" rev-parse HEAD 'HEAD^{tree}' # f4ba205… / 88cb4f3…
# 교차 서버 규칙 (함수만 추출, env -i)
eval "$(sed -n '/^_herdr_caller_server() {/,/^}/p; /^_herdr_call_is_cross_server() {/,/^}/p' "$RP/.agents/skills/lib.sh")"
env -i HERDR_SOCKET_PATH=/x/.config/herdr/herdr.sock HERDR_SESSION_NAME=mam bash -c "$(declare -f _herdr_caller_server _herdr_call_is_cross_server); _herdr_call_is_cross_server && echo CROSS"
env -i HERDR_SOCKET_PATH=/x/.config/herdr/sessions/mam/herdr.sock HERDR_SESSION_NAME=mam bash -c "$(declare -f _herdr_caller_server _herdr_call_is_cross_server); _herdr_call_is_cross_server || echo same"
# 증거 규칙: 이전 토큰의 started / fingerprint 없는 감사 기록은 토큰 요구 시 거부 (§3-2 의 10 케이스)
# 수정 없이는 실패하는지 (사본에서만): 소스 12파일을 1e78dae 판으로 교체 후 새 테스트 2파일 → 60 failed / 12 passed
```
[VERDICT: PASS]
@@ -0,0 +1,42 @@
# PR #56 4차 재리뷰 — 수정 커밋 21f0c12 (job ae9c1f28)
- 리뷰어: planner-reviewer-claude-01 (Reviewer)
- 리뷰 identity: loop-20261012T013821Z-656dfe / base efd27def .. head 21f0c12b (커밋 1개, 2 파일) / tree c2357ac0
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261012T013821Z-656dfe/1` — HEAD·tree 가 identity 와 일치, `git status --porcelain` 0 줄(시작·종료 시). 본 체크아웃 HEAD 42e5173(main) 변동 없음.
- 실행: 워크트리 venv python 3.9.6, `env -i` 로 HERDR_*/MAM_*/MQTT_*/WORKSPACE_ROOT/CLAUDE_PROJECT_DIR/HOME_DIR/CODEX_HOME 을 비우고 호스트의 긴 TMPDIR 은 그대로. `.mam.env` 는 읽지 않았다. 범위는 이 커밋만.
## 1. 전제 확인 — 'default' 스캔은 정말 HERDR_SESSION_NAME 의 서버를 보는가 (확인할 것 1)
**코드.** reconcile 의 스캔은 `subprocess.run(['herdr', ...])` 로 PATH 의 `herdr` 를 부른다(`reconcile.sh:505-510`). lib.sh 를 source 하면 `$WORKSPACE_ROOT/.mam/shim` 이 PATH 맨 앞에 들어가고(`lib.sh:144-147`), create_session.sh 는 lib.sh 를 source 한 채 `reconcile.sh --once` 를 자식으로 띄우므로(`create_session.sh:748`) 그 reconcile 의 `herdr` 는 shim 이고 `HERDR_SESSION_NAME`(= create 의 서버, 워크스페이스 슬러그)을 물려받는다. shim 은 `-L X` 를 `HERDR_SESSION_NAME=X` 로 바꾸고(`lib.sh:184-190`), 실제 바이너리를 부를 때 `HERDR_SESSION_NAME` 이 비어 있지 않고 `default` 가 아니면 `--session <그 값>` 을 앞에 붙인다(`lib.sh:270-283`; `ls` 경로의 python 도 `MAM_LS_SESSION` 으로 같은 값을 씀, `:1256-1318`). 따라서 `-L` 없는 'default' 스캔 = `herdr --session <HERDR_SESSION_NAME> ls` 이다. 전제 성립.
**실서버(herdr 0.9.3, 일회용 HOME `/tmp/ae9c-h*`, 일회용 서버 이름).**
- lib.sh 를 source 한 셸에서 `HERDR_SESSION_NAME=t-ae9c-srv` 로 shim `herdr ls -F …`(`-L` 없음) → rc 0. 그 HOME 아래 생긴 서버는 `.config/herdr/sessions/t-ae9c-srv/` 하나뿐이고 default 소켓 `.config/herdr/herdr.sock` 은 생기지 않았다 → 'default' 스캔이 환경변수의 서버로 갔다. `-L t-ae9c-srv ls` 도 rc 0(같은 서버). 서버는 `--session t-ae9c-srv server stop` 으로 내리고 HOME 제거, 잔류 프로세스 0.
- 참고로 **원 바이너리는 `HERDR_SESSION_NAME` 을 모른다**: `/opt/homebrew/bin/herdr status server` 는 `HERDR_SESSION_NAME=t-ae9c-x` 에서 default 소켓(`…/herdr.sock`)을, `HERDR_SESSION=t-ae9c-y` 와 `--session t-ae9c-z` 에서 `sessions/<이름>/herdr.sock` 을 가리켰다. 즉 전제는 shim 을 거칠 때만 성립하는데, reconcile 은 위와 같이 shim 을 거친다(그리고 lib.sh 를 source 한 셸에서는 `herdr` 자체가 함수다). 수정이 "HERDR_SESSION_NAME 이 비어 있지 않고 default 가 아닐 때" 로 조건을 건 것은 `_real_herdr` 의 조건(`:271-274`)과 정확히 같은 경계다.
## 2. 변경 자체 (`reconcile.sh:659-666`)
- drift B 에서만, `srv == 'default'` 이고 ambient 가 유효할 때 `srv` 를 ambient 로 바꾼다. 이후의 `server_opt`, `herdr_session`/`herdr_server`, `start_command`/`attach_command`/`kill_command` 가 모두 그 `srv` 를 쓰므로 행이 일관되게 "답한 서버" 로 적힌다. `srv` 는 루프 머리(`:607`)에서 매 반복 `t['server']` 로 다시 읽히므로 다음 항목으로 새지 않는다. 스캔(`:505`)·drift A(`:594-595`)·`pane_meta(name, srv)`(`:650`, 스캔한 라벨로 호출 — 같은 ambient 로 답하므로 동일 서버) 는 그대로다.
- bash 변경 없음(`bash -n` 통과), python 3.9 호환.
- `tests/data/skill_code_blocks.json`: `create_session.sh:550` 이 실제로 `raise SystemExit(4)` 임을 확인(`sed -n 550p`), `test_issue42_skill_blocks` 통과.
## 3. 부작용 검토 (확인할 것 2)
- **다음 reconcile 에서 drift A 로 오판되지 않는가.** 행의 서버가 X(ambient)로 적히면 다음 reconcile 은 그 행에서 X 를 `unique_servers` 에 넣고(`:499-502`) `-L X` 로 스캔하므로(`:505-507`) `alive_set` 에 `(name, X)` 가 들어가고(`:581`), drift A 의 비교 `(name, X)`(`:594-595`)가 맞는다 — 데몬의 환경(HERDR_SESSION_NAME 유무·다른 값)과 무관하다. 같은 create 환경에서는 'default' 스캔도 X 로 답해 `(name,'default')` 까지 더해지지만 비교에 쓰이지 않을 뿐 해가 없다. scenario4 가 바로 이 경우다: 같은 폴더 6 개 create 가 각자 `reconcile --once` 를 띄워 서로의 pane 을 보며, 6 행이 모두 살아남아야 한다 → 통과(아래).
- **기존 'default' 행·HERDR_SESSION_NAME 없는 데몬.** 조건이 ambient 가 비어 있거나 'default' 면 발동하지 않으므로(`:665`) 종전과 같은 'default' 라벨이 적힌다. 이미 'default' 로 적힌 행의 drift A 판정은 이 커밋이 건드리지 않았다.
- **같은 pane 이 'default' 스캔과 X 스캔에 두 번 보일 때.** `herdr_sessions` 에는 `(name,'default')`·`(name,X)` 두 항목이 들어오지만 drift B 는 이름 기준으로 건너뛴다: `any(_sanitize(y) == name for y in yaml_session_names)`(`:610`) 와 등록 직후의 `yaml_session_names.add(name)`(`:715`). 어느 항목이 먼저 오든(정렬된 서버 순) 첫 항목이 X 라벨로 등록되고 두 번째는 건너뛴다 → 중복 없음. 두 경로 모두 라벨은 X 로 같다(`-L X` 는 `srv=X`, 'default' 는 ambient 로 교정).
- **`HERDR_SERVER_NAME` 만 있는 경우.** `unique_servers` 는 그 값을 넣지만(`:497-498`) 라벨 교정은 `HERDR_SESSION_NAME` 만 본다. shim 의 라우팅(`_real_herdr`)도 `HERDR_SESSION_NAME` 만 보므로 그때의 'default' 스캔은 실제로 default 서버를 본다 — 라벨 'default' 가 맞다. 일관됨.
- 비차단 메모(기존 코드): `:610` 의 `(name, srv) in yaml_session_names` 는 이름 집합에 튜플을 넣어 보는 비교라 항상 거짓이고, 실제 중복 방지는 뒤의 `any(...)` 가 한다. 동작에는 영향 없음.
## 4. 테스트 (확인할 것 3)
| 항목 | 결과 |
|---|---|
| head: `test_tier4_e2e.py::test_e2e_scenario4_parallel_flock_locking` + `test_issue50_pane_lock.py` + `test_issue42_skill_blocks.py` + `test_issue9_server_env.py` + `test_b19_headless_reconcile_fixes.py` + `test_tier1_unit.py` + `test_tier2_component.py` + `test_workspace_scope.py` + `test_b4_session_created.py` | **212 passed, 1 skipped** (464 s) |
| head: `test_issue50_dispatch_herdr.py` (실서버 16 케이스, a3 = 같은 폴더 3 개 동시 create 포함) | **16 passed** (548 s) |
| 수정 전 대조군: `git archive HEAD` 복사본에 reconcile.sh 만 efd27de 로 되돌리고 scenario4 | **실패** — `ERROR: parallel-sess-3-creator-claude already running in agent-sessions.yaml`, `assert 4 == 0` (브리프의 증상 그대로 재현) |
## 5. 잔류물
- 일회용 HOME `/tmp/ae9c-h`, `/tmp/ae9c-h2`, `/tmp/ae9c-h3` 제거, 일회용 서버 `t-ae9c-srv` 정지(잔류 프로세스 0). `/tmp/mam50-*` 박스 0, 실험 herdr 서버 0. 검증 경로에는 `.mam` 조차 생기지 않았고 `git status` 깨끗. scratch 복사본·basetemp·프로브 스크립트는 세션 scratchpad 아래. 실제 `.mam`·모니터·세션·실제 서버·에이전트 홈은 건드리지 않았다(실제 서버에 대해서는 `herdr status` 읽기 한 번뿐).
## 결론
'default' 스캔이 shim 을 거쳐 HERDR_SESSION_NAME 의 서버를 본다는 전제는 코드와 실서버로 확인됐고, 행을 "답한 서버" 로 적는 교정은 drift A·중복 등록·기존 행·환경변수 없는 데몬에 부작용이 없다. create 의 자기 서버 행 인수(F2)와 맞물려 scenario4 가 이 Mac 에서 통과하며, 수정 전에는 브리프의 rc 4 로 실패한다.
[VERDICT: PASS]
@@ -0,0 +1,14 @@
{
"loop_id": "loop-20261023T035928Z-2ccc02",
"base_sha": "dc886ecd6b38c01032a55a2d1c230c73362ae630",
"head_sha": "1614af5b673d7519d3a38f63e3636055592b4f14",
"tree_sha": "f1af6fdd548ee5d6f6b81e2202833edfaca98188",
"review_commit": "1614af5b673d7519d3a38f63e3636055592b4f14",
"reviewer": "planner-reviewer-claude-01",
"job_id": "af549b13",
"verdict": "PASS",
"transport": "herdr",
"evidence": "review-tree",
"isolation": "worktree",
"verified_at": "2026-10-23T05:16:58Z"
}
@@ -0,0 +1,114 @@
# PR #69 (Issue #63) 리뷰 — head 1614af5
- 리뷰어: planner-reviewer-claude-01 (macOS, bash 3.2, /usr/bin/python3 3.9.6)
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261023T035928Z-2ccc02/1`
- identity 확인: HEAD 1614af5b673d7519d3a38f63e3636055592b4f14, tree f1af6fdd548ee5d6f6b81e2202833edfaca98188, `git status --porcelain --ignored` 비어 있음, `.mam` 없음 (시작·종료 시 모두). main HEAD dc886ec 그대로.
- 범위: base dc886ec..1614af5 전체(커밋 13개).
- ede9c62 까지는 이전 리뷰(잡 8445af7f, 55f86b49)에서, 8f64174·7cac6f6 은 잡 1c6eca2b 에서 봤다. 그 결론을 다시 확인했다.
- 이번에는 1614af5 와 세 수정 사이의 정합성을 중점으로 봤다.
- 참고: 같은 head 를 맡은 잡 906427d1 은 관련 스위트 도중 검증 경로(`…a646cb/3`)가 사라져 끝내지 못했다. 그 잡에서 얻은 head 결과(issue63 27 passed, 변이 결과)는 같은 tree 의 스크래치 사본에서 나온 것이다. `diff -rq` 로 이번 검증 경로와 같음을 확인했다.
## 1. 1614af5 가 바꾼 것
- `.agents/skills/lib_py/agents/id_order.py:223`: `elif sess == me or not sess:` 를 `elif not sess:` 로 바꿨다.
- 그래서 호출자 자신의 세션 이름에 남은 죽은 owner 마커도 고아 규칙을 따른다. 이번 시도의 행이 등록되면 지우고(:225), herdr 가 정상 목록으로 "없음"이라 답하면 지우고(:228-229), 그 밖에는 남긴다.
- `_read_and_prune_markers` 의 `me` 인자를 지웠다(:183, :308).
- `blockers` 는 ede9c62 부터 `m_sess == me` 를 건너뛰지 않는다(:136-144). 그래서 남은 자기 이름 마커가 실제로 막는다.
## 2. 세 수정이 각 지적을 닫는가, 서로 어긋나지 않는가
1. **8f64174 — 조회 실패를 "없음"으로 읽던 문제**
- `herdr_session_state` (:58-86) 는 정상 파싱한 `agent list` 에 이름이 없을 때만 False 를 돌려준다. 그 밖에는 None 이다.
- prune 은 `drop = live is False` (:228) 다.
- 변이 m1(`not live`)과 m2(rc≠0 → False)가 테스트에 잡혔다(잡 1c6eca2b).
2. **7cac6f6 — 상한 0 에서 30 초 조회, server_not_running**
- 조회 시간은 `deadline - now` 다(:288). timeout≤0 이면 None 을 돌려준다(:64-65).
- 변이 m4(고정 30 s)와 m7(timeout≤0 에도 조회)이 잡혔다.
- 상한 0 테스트는 0.3 s 안에 끝나는 것을 확인한다(test :1222-1224).
3. **1614af5 — 같은 이름의 죽은 owner 마커를 확인 없이 지우던 문제**
- 옛 규칙(`sess == me` 이면 drop)을 되돌린 변이 사본에서는 두 테스트가 실패한다: `test_id_order_dead_owner_marker_blocks_while_its_unregistered_herdr_session_is_alive`, `test_id_order_real_probe_against_mock_herdr_fails_closed`.
- 커밋 메시지가 말한 "재시도의 실패 정리가 살아 있는 pane 을 죽임"도 막힌다. create 의 대기(`create_session.sh:287`)는 롤백 trap 설치(:367)보다 앞서 있어서, 대기에서 거부(exit 1)되면 `cleanup_herdr_on_error` 가 돌지 않는다.
4. **정합성**: 세 수정은 모두 `_read_and_prune_markers` 의 "죽은 owner 마커" 분기 하나를 쓰고, 조회는 `bounded_probe` 하나를 거친다. 자기 이름 마커만 다른 규칙을 쓰던 예외가 사라져서 규칙이 하나로 모였다. 어긋나는 곳은 찾지 못했다.
## 3. 흔한 사용을 600 초 대기로 막지 않는가
스크래치에서 `id_order.wait` 를 프로세스 안에서 직접 불렀다. 조회 함수를 주입하고, cap 2 s(일부 0 s)로 돌렸다.
**정상 사용 (실패 없음)**: 막지 않는다.
- create 마커가 남은 채로, 그 세션이 다음 상태일 때 같은 이름으로 resume 했다:
- stopped 이고 own id 가 기록됨
- stopped 이고 id 없음 (herdr-dead stop)
- reconcile 이 terminated 로 바꿈
- 세 경우 모두 herdr 가 **답하지 못해도**(None), cap 0 에서도 rc=0 이었다.
- `blockers` 의 `pinned_for_attempt` 와 `ended_after_attempt` (:158-171) 가 이 경우들을 걸러낸다.
- 정상 create/resume 이 끝난 뒤의 마커는 행이 running 인 동안에는 `_registered_for_attempt` (:177-180) 로 지워진다.
**실패 뒤 재시도**:
| 경우 | herdr 가 "없음" 응답 (서버 살아 있음) | herdr 무응답 (서버 꺼짐 등) |
|---|---|---|
| create X 가 행 쓰기 전에 실패(exit 42 롤백) → create X 재시도 | rc=0, 0.0 s | rc=1, cap 만큼 대기 |
| resume X 가 spawn 에서 실패(행 그대로) → resume X 재시도 | rc=0, 0.0 s | rc=1, cap 만큼 대기 |
| 위 resume 실패 → 같은 cwd 에서 create Y | rc=0, 0.0 s | rc=1, cap 만큼 대기 |
- 7cac6f6 사본으로 같은 시뮬레이션을 돌리면 무응답 열의 위 두 줄(같은 이름 재시도)이 rc=0 이다.
- 즉 1614af5 는 서버가 살아 있는 흔한 재시도는 그대로 두고, herdr 가 답하지 못하는 재시도만 막는다. 이는 아래 R1 이 같은 이름까지 넓어진 것이다.
## 4. 그 밖의 확인
브리프 항목 1·2·4·5·6·8(완료 조건 테스트, autopin 재사용, resume·stop, 지운 심볼, issue34 무관)은 1614af5 가 `session_ids.py`, `lib.sh`, 셸 스크립트, 문서를 건드리지 않으므로 이전 리뷰의 결론 그대로다.
**macOS**:
- `/usr/bin/python3` 3.9.6 으로 `lib_py.agents.id_order` import 가 성공한다(잡 1c6eca2b; 1614af5 는 문법 요소를 더하지 않는다).
- 잠금은 `fcntl.flock` 이다(:294-295).
**실제 herdr 를 건드리지 않았는가**:
- 기존 서버 두 개(pid 3407 `herdr server`, 8323 `herdr --session multi-agent-mux server`)는 스위트 전후로 같다(시작 시각 08:21).
- 스위트 중 13:22 에 `herdr --session mam-pylecturenotes server` 가 새로 보였다. 하지만 이 프로세스는 실제 HOME 과 `WORKSPACE_ROOT=/Users/godopu16/PuKi/lac/curriculum/01_python_getting_started_lecturenotes` 로 떠 있고, 사용자의 attach 클라이언트(13:27)가 붙어 있다. 테스트는 HOME 을 스크래치로 바꿔 돌므로 다른 작업 공간의 것이다.
## 5. 실행한 테스트
모두 `env -i`, 임시 HOME/XDG, `PYTHONDONTWRITEBYTECODE=1 -p no:cacheprovider --basetemp=<scratchpad>` 로 돌렸다. 테스트에서 .mam.env 를 export 하지 않았다.
| 대상 | 결과 |
|---|---|
| 검증 경로: `tests/test_issue63_same_cwd_ids.py` + 관련 19개 모듈 (issue40_auto_pin, issue64_ro_connect, issue10_13_purge, issue31_purge_residual, issue42_skill_blocks, tier1_unit, tier2_component, b4_session_created, issue24_resume_dialogs, issue30_stall_watch_stop, issue23_24_startup_path, issue45_restore, issue50_pane_lock, c1_tui_readiness, issue6_fail_closed, issue60_opencode_v2, issue60_linux_fixes, uuid_target, issue17_workspace_root_isolation) | **597 passed, 2 skipped, 0 failed** (32 분) |
| 같은 tree 스크래치 사본: issue63 단독 | 27 passed |
| base 사본 + head 테스트 | 27 errors (`ImportError: id_order`). 조건별 base 실패는 1차에서 22 failed + 1 passed 로 확인했다 |
| 변이: 1614af5 를 되돌림(자기 이름 마커를 drop) | 2 failed (위 두 테스트) |
## 6. 차단 결함
없음.
## 7. 비차단
### R1 (잡 1c6eca2b 의 R1, 범위가 넓어짐): herdr 가 답하지 못하는 고아 마커는 같은 폴더·같은 종류의 create/resume 을 매번 cap 동안 붙잡는다
- 이제 같은 이름 재시도도 포함된다(위 표).
- 서버가 꺼진 상태(server_not_running)가 대표적이다.
- 조회는 서버를 띄우지 않는다(:67).
- create/resume 은 대기를 통과해야 spawn 으로 서버를 띄운다(`create_session.sh:287`→:289, `resume_session.sh:243`→:260).
- 그래서 다른 무언가가 서버를 켜기 전까지는 기본 600 s 를 기다린 뒤 거부가 반복된다.
- 회복 경로는 있다: 메시지의 `HERDR_SESSION_NAME=<srv> herdr kill-session -t <s>` 를 shim 으로 실행하면 서버가 기동된다(잡 1c6eca2b 의 가짜 herdr 재현에서 `--session srv-down server` 1회).
- 생기는 조건은 "행 등록 전 실패나 kill"에 "서버가 꺼짐"이 겹치는 경우다. 정상 사용과 서버가 살아 있는 재시도는 막지 않는다(3절).
- 권고 (둘 중 하나):
- (a) server_not_running 만은 "없음"으로 읽는다. MAM 서버는 restore 가 꺼져 있어서 꺼진 서버 위에는 살아 있는 pane 이 없다. `lib.sh:204-207` 의 ISSUE-9 규약과도 맞는다.
- (b) 그 경우는 cap 을 기다리지 말고 바로 거부한다. 메시지는 "서버가 꺼져 있음 → 위 명령으로 정리"처럼 사실대로 쓴다.
- fail-closed 를 택한 것은 루프에서 정한 설계 선택이라 차단으로 두지 않는다.
### 그 밖의 비차단 (잡 1c6eca2b 와 같음)
- **N1**: 조회가 `MAM_NO_AUTOSTART=1` 을 쓴다는 것(:67)을 고정하는 테스트가 없다.
- **N2**: sanitize 한 이름 비교(:85)를 고정하는 테스트가 없다.
- **N3**: `flock(LOCK_EX)` 대기(:295)는 시간 제한이 없다. 다른 호출자가 앞 대기의 남은 시간만큼 더 기다릴 수 있다. 상한은 있다.
- **N4**: create·resume SKILL.md 의 exit 1 설명이 고아 마커로 거부되는 경우와 그 정리 방법을 말하지 않는다.
- **N5**: 이전 라운드의 비차단 항목이 남아 있다: resume `mam_id_order_wait` 제거가 테스트로 고정되지 않음, c6(b) 의 1.5 s 타이밍 flake, lib.sh `--herdr-server` 전달이 테스트되지 않음.
## 8. 결론
- 1614af5 는 codex 의 3차 지적(같은 이름의 죽은 owner 마커를 확인 없이 지움)을 닫는다. 8f64174·7cac6f6 과 같은 고아 규칙 하나로 모였다.
- 정상 create/resume 과 서버가 살아 있는 재시도는 막지 않는다.
- herdr 가 답하지 못할 때 cap 동안 기다리는 비용(R1)은 회복 경로가 있는 설계 선택이므로 비차단이다.
[VERDICT: PASS]
@@ -0,0 +1,35 @@
# 리뷰 리포트 — Job b0c15e45 (PR #51 2회차 델타, 518beda..4c86107)
- 리뷰어: planner-reviewer-claude-01 (claude)
- 리뷰 identity: loop-20261007T155310Z-56d5a7 / base 518beda / head 4c86107 / tree e6f67f6 — 검증 경로에서 `git rev-parse HEAD 'HEAD^{tree}'` 일치, 미커밋 변경 없음
- 범위(항목 4): lib_py/delegation/wrapper.py `_sleep`(+6/−3, 주석 포함)과 테스트 1개 추가. 래퍼 bash·문서·버전 변경 없음.
## 결론 요약
codex R1 이 닫혔다. monotonic 은 `_sleep` 의 대기 길이에만 쓰이고, 마감·evidence 창·이벤트 시각은 벽시계 그대로라 옛 bash 정책과 같다. 새 테스트는 결정적이며 이전 코드에서 실패한다. 차단 결함 없음.
## 1. R1 종결
| 지적 (codex 1회차) | 수정 | 확인 |
|---|---|---|
| R1 `_sleep` 이 `time.time()` 으로 남은 시간을 재서, 대기 중 벽시계가 뒤로 바뀌면 대기가 늘어남(1 s→11 s, 3 s→13 s) | wrapper.py:145-156 — `end`/`left` 를 `time.monotonic()` 으로 계산 | 새 테스트 test_u_a_wait_is_not_stretched_by_a_wall_clock_step_back[1, 3] 통과. 이전 wrapper.py(518beda)를 넣은 스크래치 사본에서는 `assert 11.0 <= 1.5`, `assert 13.0 <= 3.5` 로 **2 failed** — 지적한 증상 그대로 재현 |
## 2. 벽시계 정책이 섞이지 않는가
- monotonic 이 쓰이는 곳은 `_sleep`(:150, :153) 뿐이다. 벽시계는 그대로: `_now()`(:45-46, bash `date +%s`), 마감 `start_time + timeout`(:203), broker 재관측의 남은 시간 `deadline - _now()`(:208)와 마감 도달 판정(:218), 유예 루프 조건·evidence 창·마감 판정(:226-240), 디스크 heartbeat 의 나이 `time.time() - seen`(:115, 이벤트 timestamp 가 벽시계이므로 맞다).
- 옛 bash 와 같다: `sleep 1`/`sleep "$left"` 는 경과 시간(벽시계 무관)이고, 마감·창은 `$(date +%s)`. broker 루프는 `min(deadline - _now(), 3)` 을 벽시계로 구한 뒤 그 길이만큼 monotonic 으로 잔다 — bash 의 `left` 계산 후 `sleep "$left"` 와 동일.
- 래퍼 생존 확인(0.5 s 간격)은 `_sleep` 안의 `time.sleep(min(left, 0.5))` 와 매 반복 `_check_owner` 로, 간격 자체가 monotonic 남은 시간 기준이 되었을 뿐 확인 빈도·조건은 같다.
## 3. 테스트 결정성
- `_SteppedClock` 이 모듈의 `time` 을 대체해 실제 시간·sleep 을 쓰지 않는다(0.1 s 에 끝). 첫 sleep 중 벽시계만 10 s 역행시키고 `slept` 를 정확히 누적하므로 결과는 입력으로 결정된다(현재 코드에서 slept == seconds). 3회 반복 모두 통과. 상한 `seconds + 0.5` 와 옛 결과(+10)의 차이가 커서 오탐 여지가 없다.
- 이 테스트는 벽시계가 **앞으로** 뛰는 경우는 다루지 않지만, 그 경우는 옛 bash 와 마찬가지로 마감 쪽(벽시계)이 일찍 끝나는 것이 정책이므로 대상이 아니다.
## 4. 테스트 실행
| 실행 | 결과 |
|---|---|
| 지정 2개 파일(test_issue17_item4_decisions, test_issue33_budget), pr-51 .venv Python 3.9.6 | **278 passed / 0 failed** (325 s) |
| 새 테스트, 이전 wrapper.py 사본 | 2 failed (11.0, 13.0) |
- 브리프 주의대로 모든 실행을 `env -i` 로 HERDR_*·MAM_*·MQTT_*·WORKSPACE_ROOT 를 비우고 임시 HOME(스크래치)으로 돌렸다(탐침 사본에는 WORKSPACE_ROOT 를 그 사본으로 명시). 실제 .mam·다른 워크스페이스·herdr 서버를 건드리지 않았다.
- PR 설명의 전체 스위트 실패 1건(test_issue45_restore 의 $HOME 복원, herdr 0.9.3)은 이 델타가 건드리지 않는 파일이며 main 에서도 같다는 설명과 일치한다(이번에 재실행하지 않음).
## 5. 비차단
1회차 N1~N3·N5 는 후속으로 남겨 둔 데 동의한다. 이번 델타에 새 관찰은 없다.
[VERDICT: PASS]
@@ -0,0 +1,14 @@
{
"loop_id": "loop-20261015T123502Z-1b9df6",
"base_sha": "e32c9574644a9185813a4dc881812694d62b6204",
"head_sha": "a7cb90e63abf454ccf22f6ff887cc25393755402",
"tree_sha": "96c5c5f82cb89e4383011d52fb1dcb21d4e787e6",
"review_commit": "a7cb90e63abf454ccf22f6ff887cc25393755402",
"reviewer": "planner-reviewer-claude-01",
"job_id": "b48be8a6",
"verdict": "PASS",
"transport": "herdr",
"evidence": "review-tree",
"isolation": "worktree",
"verified_at": "2026-10-15T13:18:35Z"
}
@@ -0,0 +1,58 @@
# PR #58 (fix/issue-57) 병합 전 2차 리뷰 (job b48be8a6)
- 리뷰어: planner-reviewer-claude-01 (Reviewer)
- 리뷰 identity: loop-20261015T123502Z-1b9df6 / base e32c9574 .. head a7cb90e6 (커밋 15개; 1차 이후 대응 4개: 75c0042 F1, 151af99 F2, 1c598bb R1, a7cb90e R2) / tree 96c5c5f8
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261015T123502Z-1b9df6/1` — HEAD·tree 일치, `git status --porcelain` 0 줄, `.mam` 없음(시작·종료 모두). 본 체크아웃 HEAD e32c957(main) 변동 없음.
- 환경: macOS, `/usr/bin/python3` 3.9.6(paho 없음; 이 Mac 의 PATH `python3`), 체크아웃 `.venv` 3.9.6(paho 2.1.0), PATH `bash` = `/bin/bash` 3.2.57.
- 실행 안전: 테스트·탐침은 모두 `env -i`(PATH/HOME/LANG/TMPDIR 만, 필요한 값만 명시)로 브리프의 `-u` 목록 전부를 비웠고, basetemp 는 scratchpad 아래(이름에 "shim" 없음). 수동 재현은 scratchpad 또는 `/tmp/jb48-r2`(종료 후 삭제)의 임시 워크스페이스·cwd 에서만 했다. 수정 전 대조군은 `git archive HEAD` 복사본에 제품 파일만 되돌려 만들었다. 실제 `.mam`·`.mam.env`·모니터·herdr 서버·에이전트 홈 미접촉.
## 1. 1차 지적 4건 — 재현을 다시 돌린 결과
| 지적 | 1차 재현 (63d8164) | head (a7cb90e) | 수정 전 코드에서 회귀 테스트 |
|---|---|---|---|
| **F1** paho 없는 폴백에서 control_plane·status 실패 | `AGENT_PYTHON_BIN=/usr/bin/python3` 로 `mam_control_plane_json` rc=1 `ModuleNotFoundError: paho`; `get_job_status` 예외 | rc=0, 바인딩 JSON 에 로그 루트 포함; `status.sh` 의 `get_job_status` 두 복사본 모두 `('abc12345', 'unknown')` | `test_control_plane_binding_works_without_paho`, `test_status_works_without_paho[json/text]` **실패**(paho 미존재로 control_plane·service import 실패) |
| **F2** 래퍼 `logs` 기본 경로가 cwd 상대 | `WORKSPACE_ROOT=ws`, cwd=elsewhere 에서 `logs --list` 가 잡을 못 찾음 | `logs --list` 가 ws 의 잡을 나열, `logs <id>` 에 `registered` 이벤트, `--registry-dir` 를 주면 그 레지스트리를 따름, elsewhere 에 아무것도 안 생김 | `test_logs_without_registry_dir_follows_the_workspace_from_any_cwd` **실패**(`(no logged jobs under …)`) |
| **R1** 디스크 폴백이 이후 디스패치의 terminal 을 이전 시도로 오인 | (codex 지적) | 감사 상태 채택 직전에 레코드를 다시 읽어 `(dispatch_token, attempt_id, transition_seq)` 가 처음 읽은 것과 같을 때만 채택 | `test_disk_fallback_rejects_a_terminal_that_belongs_to_a_later_dispatch` **실패**(`B's completion was attributed to A`) |
| **R2** jobs 디렉터리가 심볼릭 링크일 때 submit 과 logs 불일치 | (codex 지적) | `/tmp/jb48-r2/alias/jobs -> actual/jobs` 로 `submit --dry-run`: 출력 경로·감사 로그 모두 `actual/delegate_job_logs/<id>`; `logs --registry-dir alias/jobs` 와 `registry.py --registry-dir actual/jobs logs --list` 가 같은 잡을 찾음 | `test_a_symlinked_registry_dir_resolves_to_one_audit_log` 는 63d8164 에서는 `logroot` 부재로 ImportError 라 증거가 안 되므로, **a7cb90e^ (logroot 있음, `abspath` 규칙)** 로 다시 돌려 **실패**(`…/alias/… ≠ …/actual/delegate_job_logs`) 확인 |
## 2. 수정이 새 문제를 만들지 않았는가
**F1 (모듈 이동)**
- `lib_py/delegation/logroot.py` 는 stdlib(`os`, `typing`)만 쓴다. `mqtt_common` 이 `DEFAULT_REGISTRY_DIR`·`_workspace_mam_dir`·`default_registry_dir`·`get_logs_dir`·`sibling_logs_dir` 를 그대로 재노출하므로 기존 import 경로(`mqtt_common.get_logs_dir` 등)는 불변이다.
- `from lib_py.delegation.logroot import …` 는 패키지 `__init__` 을 거쳐 `daemon`·`service`·`registry`·`publish_event`·`publisher` 까지 로드한다. 이 Mac 의 `/usr/bin/python3` 로 직접 import 해 보니 12 개 `lib_py` 모듈이 모두 올라오고 `paho` 는 로드되지 않았다. 직접 paho 를 import 하는 곳은 `mqtt_common`·`publisher` 둘뿐이고 둘 다 `try/except ImportError` 로 감쌌으며 `from __future__ import annotations` 라 `-> mqtt.Client` 주석도 import 시 평가되지 않는다. 실제로 브로커가 필요하면 `make_client` 가 명확한 `ImportError` 를 낸다.
- eager import: `logroot` 는 `eager.CORE_MODULES` 에 없지만 목록에 있는 `mqtt_common` 이 import 시점에 로드하므로 동결 런타임에 함께 들어간다(아래 비차단 메모).
**F2**: `local -a reg=()` + `${reg[@]+"${reg[@]}"}` 를 `/bin/bash -u` 로 직접 확인 — 빈 배열이면 인자 없음, 주어지면 `--registry-dir <dir>`. 래퍼의 다른 subcommand 는 바뀌지 않았다.
**R1 (재읽기)**
- 경합: B 의 terminal 감사 상태는 재디스패치(레코드 토큰·seq 변경)가 커밋된 뒤에만 생긴다. 그러므로 감사 읽기에서 B 의 terminal 이 보였다면 레코드는 이미 그 전에 바뀌었고, 그 뒤의 재읽기가 반드시 차이를 본다. 재읽기 이후의 변경은 이미 읽은 감사 상태와 무관하다.
- 같은 시도 안에서 정상 전이(A running → A completed)가 두 읽기 사이에 끼면 seq 가 달라져 "deferred" 로 한 라운드 늦어질 뿐이고, 다음 라운드는 레코드 자체의 terminal 을 읽는다.
- 성능: 감사 상태가 terminal 이고 stale 이 아닐 때만 레코드 하나를 더 읽는다. 무시할 만하다.
**R2 (realpath)**
- 기본 경로: `WORKSPACE_ROOT=/tmp/...`(macOS 에서 `/tmp → /private/tmp`)일 때 `get_logs_dir()` 은 이제 `/private/tmp/.../.mam/delegate_job_logs` 문자열을 돌려준다. 물리적으로는 이전과 같은 디렉터리임을 확인했다. 결과를 문자열로 비교하는 곳은 `control_plane.forwarded_keys`(이제 양쪽 모두 `_abs`=realpath)와 루프 러너의 제외 목록(`runner.py` 가 `d.resolve().relative_to(root.resolve())`)뿐이라 영향이 없다.
- `forwarded_keys` 탐침: `/tmp`·`/private/tmp` 혼합 두 방향, 후행 `/`, 심볼릭 링크 alias 경유 모두 형제로 판정되어 전파하지 않는다. 비형제만 전파한다. 1차에서 비정규 입력이 "전파" 쪽으로 어긋나던 비대칭도 사라졌다.
- `from_env({"WORKSPACE_ROOT": "/tmp/..."})` 의 기본 바인딩은 `defaults()` 와 같아서, 평소 실행은 pane 에 아무것도 전파하지 않는다.
- 레지스트리 디렉터리 자체가 심볼릭 링크인 경우에만 감사 로그가 링크 대상 옆으로 간다. 이는 R2 가 의도한 동작이며, 래퍼(`pwd -P`)와 바인딩(realpath)이 이미 그렇게 보고 있었다.
## 3. 이 Mac 정합성
- 변경 py 15 개: `ast.parse(feature_version=(3,9))` 와 `/usr/bin/python3 -m py_compile` 모두 통과. 3.10+ 문법·API 없음.
- `lib.sh`·래퍼·`status.sh`: `/bin/bash -n`(3.2) 통과.
## 4. 테스트 (head, 검증 경로)
| 묶음 | 결과 |
|---|---|
| A: issue57·15·21_acceptance·21_worktree_prereqs·tier1·20·17_item4·17_workspace_root_isolation·36·14 | **475 passed, 2 skipped** |
| B: issue18·6_fail_closed·34·33·5_phase1/2a/2b/4_runtime·tier2·12·23_24·21_phase2·b7 | **493 passed** |
`5_phase4_runtime` 의 eager 모듈 검사도 통과했다. 어느 실행도 검증 경로에 `.mam` 을 남기지 않았고 #55 가드 오탐도 나오지 않았다.
## 5. 비차단 메모
- `lib_py.delegation.logroot` 를 `eager.CORE_MODULES` 에 명시하면 의도가 분명해진다. 지금도 `mqtt_common` 을 통해 로드되므로 기능 문제는 없다.
- paho 없이 `publisher` 의 브로커 경로를 직접 타면 `make_client` 의 `ImportError` 가 먼저 나는 구조다. 다른 진입점이 `mqtt.` 를 먼저 건드리면 `AttributeError: 'NoneType'` 로 보일 수 있지만, 브로커가 필요한 경로는 원래 paho 가 필수다.
## 결론
1차 지적 4건(F1·F2·R1·R2)이 모두 해소됐다. 1차 재현을 다시 돌려 확인했고, 각 회귀 테스트가 수정 전 코드에서 의도한 이유로 실패한다(R2 는 직전 부모 커밋 기준). 모듈 이동·재읽기·realpath 는 기본 경로, 바인딩·전파, 기존 import 경로에 새 문제를 만들지 않으며, 이 Mac 의 Python 3.9·bash 3.2 에서 정합하다.
[VERDICT: PASS]
@@ -0,0 +1,14 @@
{
"loop_id": "loop-20261019T134017Z-d90c23",
"base_sha": "3ce5d5fdc108b0435e933a251fe1c1256b5d7973",
"head_sha": "b0eccc530149bc6e9211727e19921665c7b80492",
"tree_sha": "f5a412a3ee96465db7c4b3223d2d76e95716ddfa",
"review_commit": "b0eccc530149bc6e9211727e19921665c7b80492",
"reviewer": "planner-reviewer-claude-01",
"job_id": "b78af5ad",
"verdict": "PASS",
"transport": "herdr",
"evidence": "review-tree",
"isolation": "worktree",
"verified_at": "2026-10-19T14:59:46Z"
}
@@ -0,0 +1,97 @@
# Issue #64 리뷰 — agy·codex·hermes DB 읽기 전용 열기 (job b78af5ad)
- 리뷰어: planner-reviewer-claude-01
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261019T134017Z-d90c23/1`
- identity: HEAD `b0eccc530149bc6e9211727e19921665c7b80492`, tree `f5a412a3ee96465db7c4b3223d2d76e95716ddfa` 가 일치한다. 작업 트리는 clean 이고 `.mam` 은 없다.
- 범위: base `3ce5d5f`(v4.9.7)..head 1커밋. 판정은 base..head 전체에 대한 것이다.
- 참고 자료: creator(agy) 리포트 `.mam/jobs/c721e4f5/agy-reports/report-final.md`
- 환경: macOS, Python 3.9.6, sqlite 3.51.0. 실제 에이전트 홈은 `ls`·`lsof`·`ps` 로만 봤고, sqlite 로 연 적은 없다.
## 결론
이슈 범위는 빠짐없이 바뀌었다. 조회 전용 열기는 모두 `_ro_connect` 로 바뀌었고, 쓰기와 MAM 자신의 DB 는 그대로다. 새 테스트는 base 에서 실패하고 head 에서 통과한다. 원인도 직접 재현해 확인했다. 차단 결함은 없다. 다만 새 런타임 테스트의 reconcile 단계가 실제로는 drift C 를 실행하지 않는다(N1). 또 기존부터 있던 workspace 간 오고정 문제를 새 테스트가 기대값으로 고정해 버렸다(N2). 둘 다 테스트를 보강하라는 권고다.
## 1. 원인 확인 — 직접 재현함
creator 의 결론: `reconcile.sh` drift C(agy, `:786-839`)는 고정되지 않은(unpinned) 실행 중 agy 행이 있으면 호스트 공용 `conversations/*.db` 를 전부 훑는다. 각 DB 에 `verify_session_uuid` 를 부르고, 이어서 `AgyAgentAdapter.verify_artifact` 가 `sqlite3.connect(path)` 로 연다. `last_conversations.json` 이 그 대화를 다른 workspace 의 것으로 적어 두어도 `cache_match=False` 는 `_sibling_claimed_uuids` 만 보고 거르지 않는다(`agy.py:60-72`). 그러면 macOS persistent WAL 때문에 `-wal`·`-shm` 이 남는다.
재현 방법:
- git archive 사본에 임시 테스트를 넣었다(mam_sandbox, 임시 HOME, mock herdr).
- 사이드카 없는 WAL agy DB 하나를 만들고, `last_conversations.json` 은 그 대화를 다른 workspace `ws_b` 에 매핑했다.
- ws 의 고정되지 않은 running 행을 mock herdr 에 살아 있는 pane 으로 등록했다.
- `reconcile.sh --once --emit-diff --dry-run` 을 실행했다.
| 사본 | DB 디렉터리 결과 |
|---|---|
| base | `<uuid>.db`, `.db-shm`, `.db-wal` — 사이드카가 생긴다 |
| head | `<uuid>.db` 만 — 바뀌지 않는다 |
이로써 v4.9.7 리뷰 때 #55 가드가 다른 workspace 의 agy DB 옆에서 잡은 사이드카는 MAM 의 reconcile drift C(status·create 가 부르는 경로 포함)가 만든 것으로 설명된다. 다만 그때 그 시각에 고정되지 않은 agy 행이 실제로 있었는지는 기록이 없어 확인하지 못했다.
## 2. 바뀐 곳 확인
- **공용 helper.** `_ro_connect` 가 `base.py:111-135` 로 옮겨졌다. 본문은 그대로이고, 임시 복사본 이름만 `os.path.basename(path)` 를 쓴다.
- opencode 는 base 의 것을 import 한다(`opencode._ro_connect is base._ro_connect`, 테스트로 고정).
- opencode 에서 쓰지 않게 된 `quote`·`tempfile` import 는 지워졌다. `shutil`·`sqlite3` 는 아직 쓰고 있다(`which`, purge).
- **`db_rows_naming` 기본 연결.** 기본값이 `_ro_connect` 가 됐다(`base.py:145`). 그래서 codex·opencode 의 residual 확인도 clean WAL DB 를 읽는다. base 에서는 `mode=ro` 가 Mac 에서 `unable to open` 으로 실패해 빈 결과를 냈다.
- **조회 전용 열기를 바꾼 곳:**
- agy `verify_artifact`(`agy.py:74`)
- codex `_find_rollout_via_db`(`codex.py:80`), `discover`(`:195`)
- hermes `verify_artifact`(`hermes.py:51`), `discover`(`:100`)
- status.sh hermes·codex 두 모드(`:90-91`, `:131-132`, `:290-291`, `:331-332`)
- reconcile.sh hermes drift(`:865`), codex drift(`:1048`)
- **남은 `sqlite3.connect`(grep 으로 전수 확인).** 모두 대상이 아니라는 판단이 맞다.
- 의도된 쓰기: codex·hermes·opencode purge 의 DELETE
- MAM 자신의 DB: `lib.sh:1599,2068`, `atomic_yaml.py:61`, `state.py:17`, `verify_session.py:41`(agent-sessions.db), `loop/runner.py:416`
- **import 정리.** agy 에서 `sqlite3` import 를 지웠고 다른 사용처는 없다. status.sh 의 opencode 블록은 이제 `lib_py.agents.base` 에서 import 한다.
## 3. 테스트 실행
조건: `env -i`, 임시 HOME, `XDG_DATA_HOME=<scratchpad>`, basetemp 는 scratchpad, `-p no:cacheprovider`, `PYTHONDONTWRITEBYTECODE=1`.
**새 모듈 `tests/test_issue64_ro_connect.py`**
- head: 43 passed.
- base 사본에 이 파일만 넣으면: **23 failed, 20 passed**. creator 의 수치와 같다.
- base 에서 실패한 것은 모든 clean·walonly 경우, 정적 검사, workspace 간 테스트다. 실패 메시지는 사이드카 `-shm`·`-wal` 이 생겼다는 것이다.
**관련 13개 모듈**
- 대상: issue64, issue60_linux_fixes, issue60_opencode_v2, a4, issue10_13_purge, issue31, orc_onboard, tier1, tier2, uuid_target, issue55, issue57, issue21_worktree_prereqs.
- 결과: **461 passed, 1 skipped, 실패 0**, 15분. skip 은 PATH 에 opencode 가 없는 실바이너리 계약 테스트다. #55 가드 오탐은 없었다.
**뮤테이션** (git archive 사본, issue64 모듈)
| 뮤테이션 | 결과 |
|---|---|
| agy `verify_artifact` 를 `sqlite3.connect` 로 | 5 failed(workspace 간 테스트 포함) |
| `db_rows_naming` 기본값을 옛 `mode=ro` URI 로 | 4 failed |
| hermes `discover` 를 `sqlite3.connect` 로 | 2 failed |
| reconcile hermes drift 를 `sqlite3.connect` 로 | 1 failed — 정적 검사만 잡음 |
| reconcile codex drift 를 `sqlite3.connect` 로 | 1 failed — 정적 검사만 잡음 |
**린트**
- `git diff --check` 는 깨끗하다.
- 바뀐 Python 6개 파일이 3.9 로 파싱된다.
- status.sh·reconcile.sh 는 `/bin/bash -n`(3.2)을 통과한다.
## 비차단
- **N1. 런타임 테스트의 reconcile 단계는 drift C 를 실행하지 않는다.**
- 위치: `test_status_and_reconcile_runtime_leave_sidecars_alone`(`:241-...`).
- 원인: 세 행을 mock herdr 에 등록하지 않는다. 그래서 drift A 가 먼저 `herdr gone … Marked terminated` 로 처리하고 drift C 를 건너뛴다. base 에서 같은 상황을 실행해 drift 결과로 확인했다.
- 결과: reconcile 의 hermes·codex 변경은 정적 문자열 검사로만 고정된다(위 뮤테이션 2개).
- 권고: 이슈 64 요구는 "실제 실행 또는 최소한 정적 검사"라 충족은 된다. 그래도 mock herdr state 에 세 세션을 `running` 으로 넣으면 실제 drift C 경로가 검증된다(내 재현처럼 `state["agents"][name] = {...}`). status 단계는 실제로 열린다. base 에서 hermes `state.db` 사이드카로 실패한다.
- **N2. 기존 결함: drift C(agy)가 다른 workspace 의 대화를 고정한다.**
- 내 재현에서 `last_conversations.json` 이 `ws_b` 의 것으로 적은 대화가 epoch 뒤 유일한 후보였다. 그러자 base·head 모두 ws 행에 `updated conversation id: <uuid>` 로 고정했다(dry-run 기준).
- 이 PR 이 만든 문제는 아니고 #64 범위 밖이다. 다만 새 테스트 `test_unpinned_agy_reconcile_…`(`:288`)가 `verify_session_uuid(ws_a, …, uuid_b) is True` 를 단언해 이 동작을 기대값으로 고정한다.
- 권고: 이 단언은 빼고 디렉터리 목록만 본다. 오고정은 별도 이슈로 다룬다. 예를 들어 `cache_match` 가 아니면서 lc 가 그 uuid 를 다른 cwd 에 매핑하면 거절한다.
- **N3.** `_ro_connect` 의 immutable 경쟁 창(ms 수준)은 #60 에서 남긴 것이 공용 helper 를 통해 agy·codex·hermes 로도 넓어졌다. 세 에이전트의 실제 DB 는 모두 WAL 형태다(`ls`: hermes·codex 는 사이드카 쌍이 있고, agy 대화 72개 중 7개에 사이드카가 있음). 실행 중에는 사이드카가 있으므로 `mode=ro` 갈래를 탄다.
## 관찰·공개
- **실제 codex DB 의 사이드카.** 관련 모듈 실행 전후로 `~/.codex/` 목록을 비교했더니 `state_5.sqlite-shm`·`-wal` 이 다시 나타나 있었다(mtime 23:29/23:35, 지금은 보유 프로세스 없음).
- 이 두 파일은 잡 시작 직후(23:04)에도 있었다가 사라졌던 것이다.
- 같은 시간대에 실제 `~/.codex` 를 쓰는 codex 프로세스들이 떠 있다: `codex resume`(이 루프의 리뷰어), app-server daemon, IDE 확장.
- 내 실행은 `env -i`·임시 HOME 이었고, head 코드는 사이드카를 만들지 않으며, #55 가드도 아무것도 잡지 않았다. 그래서 테스트 산출물이 아니라 실제 codex 활동으로 본다. 판정 근거로는 쓰지 않았다.
- **검증 트리에 임시 파일이 들어갔다 지워짐.** 재현 중 내 셸 명령 실수로 임시 테스트 파일(`tests/test_zz_review_agy.py`)이 검증 트리에 몇 초간 복사됐다. 그 파일로 아무것도 실행하지 않았고 즉시 지웠다. 이후 `git status --porcelain --ignored` 는 비어 있고 HEAD·tree 는 그대로다. 모든 재현은 scratchpad 사본에서 했다.
[VERDICT: PASS]
@@ -0,0 +1,14 @@
{
"loop_id": "loop-20261001T140030Z-34381f",
"base_sha": "e1eb22bd59ce825e3a22e86433c30f7ffd833810",
"head_sha": "0eec795cd1b46f7a28b9a1cc87ef8577619a731f",
"tree_sha": "584eac551f880232896b898cc43156f28ae1bacb",
"review_commit": "0eec795cd1b46f7a28b9a1cc87ef8577619a731f",
"reviewer": "planner-reviewer-claude-01",
"job_id": "b9600325",
"verdict": "PASS",
"transport": "herdr",
"evidence": "review-tree",
"isolation": "worktree",
"verified_at": "2026-10-01T14:27:22Z"
}
@@ -0,0 +1,99 @@
# 🔍 Review Report — PR #43 2회차 델타 (job `b9600325`)
- **Reviewer**: `planner-reviewer-claude-01` (role: planner,reviewer) · 2026-10-01
- **대상 델타**: `b58edac..0eec795` (1커밋, 7파일 `+128/−19`, `git diff --check` 클린), base `main e1eb22b` (v4.8.0).
- **검증 경로**: `/var/folders/.../T/mam-review/loop-20261001T140030Z-34381f/1`. **identity 확인**: `HEAD = 0eec795c…731f`, `HEAD^{tree} = 584eac55…bacb` — 브리프와 일치.
- **1회차 내 판정**: PASS + R1–R5 (job `645adb2a`). 브리프 지시대로 0eec795 만 본다.
- **안전 규칙 준수**: 검증 경로·소스 워크트리·본 체크아웃 모두 수정·커밋·checkout 없음. 실험은 scratchpad 의 합성 env 파일·임시 등록부·래퍼 `--dry-run`(도달 불가 브로커)에서만. 실제 `.mam.env` 를 읽거나 출력하지 않았다. 테스트는 `.mam.env` 미export·`MQTT_*` 미설정. 실제 `.mam`·데몬·세션·herdr 조작 없음, push·merge·PR 상태 변경 없음.
## 결론 요약
**네 수정(codex R1–R4)과 내 R1·R2 지적은 모두 닫혔고, 1회차 PASS 를 뒤집을 요소는 없다.** 각 수정을 독립 실험으로 확인했다. 읽기 실패 시 Python 진입점이 멈추는 설계에는 **동의**한다(§5). 비차단 관찰 2건(S1·S2).
## 1. codex R1 — 읽을 수 없는 파일은 오류 (`envfile.py:56-61`, `:104-109`)
| 입력 | `--shell` 결과 |
|---|---|
| 권한 0000 | rc **1**, stdout 없음, stderr `ERROR: …: cannot read (PermissionError)` |
| 깨진 UTF-8 | rc **1**, `cannot read (UnicodeDecodeError)` |
| 파일 없음 | rc 0, 출력 없음(종전대로 — "설정 없음" 은 정상) |
래퍼는 rc≠0 에 `ERROR: cannot load $TARGET_ENV (see the line above)` + exit 1(`multi-agent-mux-delegate-job:44`) — 1회차 내 R2(메시지가 원인과 어긋남)도 닫혔다: 이제 위 줄이 실제 원인(PermissionError/UnicodeDecodeError)이다. 값은 어디에도 찍히지 않는다.
## 2. codex R4 — NUL 이 든 줄 (`envfile.py:66-68`)
`A=1 / B=x\0y / C=3` 파일: `--shell` 이 `export A=1`·`export C=3` 만 내고 `WARNING: …:2: skipped (NUL byte)`. `parse` 가 두 로더의 공통 경로이므로 Python 로더도 같은 줄을 같은 경고로 건너뛴다 — 두 로더 일치.
## 3. codex R2 — 로케일과 무관한 ASCII 정수 (`lib.sh:2349-2351`)
C 와 en_US.UTF-8 두 로케일에서 bash `mam_int` 와 Python `_positive_int` 비교:
| 입력 | C | en_US.UTF-8 |
|---|---|---|
| `30` | 30 / 30 | 30 / 30 |
| `U+2003 30 U+2003`(em space) | 거부 / 거부 | **거부 / 거부** (수정 전 bash 는 30) |
| 아랍 숫자 `٣٠` | 거부 / 거부 | 거부 / 거부 |
| `' 30\t'` | 30 / 30 | 30 / 30 |
| 전각 `30` | 거부 / 거부 | 거부 / 거부 |
10/10 일치. 공백 집합 `$' \t\n\r\v\f'` 는 Python `strip(" \t\n\r\v\f")` 와 같다.
## 4. codex R3 — 명시한 빈 플래그 (`multi-agent-mux-delegate-job:167`, `:201`)
래퍼 `--dry-run` 실측: `--timeout ''` → rc 1 `ERROR: --timeout : not a positive decimal integer`, `--ack-timeout ''` → rc 1, `--timeout 0` → rc 1. 플래그를 주지 않으면 기본값(종전대로). 값 없이 `--timeout` 만 주면 `set -u` 의 `unbound variable` 로 rc 1 — 이 PR 이전부터의 동작이고 실패 방향이라 무해하다(S2).
## 5. 설계 질문: 읽기 실패에 Python 진입점이 멈추는 것이 맞는가
**멈추는 쪽에 동의한다.** 근거:
- `.mam.env` 가 실어 나르는 것은 브로커 주소·계정과 `MAM_DISPATCH_SECRET` 이다. 읽기에 실패한 채 "경고 후 계속" 하면 **기본 브로커(공개 `broker.hivemq.com`)로, 서명 없이** 잡 내용을 발행하게 된다 — 예제 파일의 SECURITY NOTE 가 경고하는 바로 그 상황이다. 설정 파일이 있는데 못 읽은 것은 "설정 없음" 과 다르다.
- 에이전트 세션 안의 영향(실측): 권한 0000 파일로 `publish_event --event started` 를 돌리면 rc 1 로 끝나고 레코드는 `pending`, `events.log` 는 생기지 않는다. 즉 래퍼의 `started` 대기가 **fail closed**(`agent_blocked`)로 잡을 닫는다 — 조용히 엉뚱한 브로커로 가는 것보다 보이는 실패가 낫다. heartbeat 타이머·구독자도 같은 진입점이라 같은 식으로 멈춘다.
- **S1 (비차단)**: Python 진입점에서는 `EnvFileError` 가 **트레이스백**으로 나온다(실측: Traceback 1개, 마지막 줄 `lib_py.delegation.envfile.EnvFileError: …: cannot read (PermissionError)`). 원인은 명확하지만, 에이전트 pane 에 그대로 찍히는 출력이다. `ensure_env_loaded` 를 부르는 진입점(또는 `ensure_env_loaded` 자체)이 `EnvFileError` 를 잡아 `ERROR: <msg>` 한 줄 + `sys.exit(1)` 로 끝내면 래퍼의 `--shell` 과 같은 모양이 된다.
## 6. 문서 — 내 1회차 R1 (`.mam.env.example:14-26`)
머리말에 FORMAT 절이 생겼다: 한 파서·`source` 안 함, 공백·따옴표 한 쌍 제거, **확장 없음**(`$HOME`·`${X}`·`$(cmd)`·백틱 글자 그대로 → 절대경로로 쓸 것), **줄 끝 `# ...` 은 값의 일부**, `export`·잘못된 키·`=` 없는 줄·NUL·bash 예약 변수는 줄 번호 경고 후 건너뜀, **환경변수가 파일보다 우선**, 읽기 실패는 오류. 1회차에 지적한 래퍼 사용자 동작 변경 4가지가 모두 적혔다 — 닫힘.
## 7. 1회차 내 지적의 처리
| # | 1회차 지적 | 0eec795 |
|---|---|---|
| R1 | `.mam.env.example` 에 규칙·env-wins 미기재 | **닫힘** — FORMAT 절 |
| R2 | `cannot read` 메시지가 원인과 어긋남 | **닫힘** — 실제 읽기 실패가 rc 1 + 원인 줄, 래퍼는 "see the line above" |
| R3 | `[ -ge ]` 전용 노브의 비숫자 처리 | 열림(범위 밖, PR 이 분류표로 의도 처리) |
| R4 | purge 12건 기준 실패 미재현 | 열림(델타와 무관; §8 재확인) |
| R5 | `mam_int` 를 항목 4 이전 목록에 | 열림(후속) |
## 8. 테스트 (검증 방법)
```
tests/test_issue26_envfile.py tests/test_issue26_ints.py tests/test_local_mqtt.py tests/test_issue18_liveness_contract.py tests/test_tier1_unit.py
(.mam.env 미export, MQTT_* 미설정)
→ 174 passed in 210.56s failed 0 · skipped 0
```
- 로그에 공개 브로커·실제 브로커 IP 흔적 0건 — 외부 브로커 없이 통과.
- 새 회귀(권한 0000·깨진 UTF-8 에서 래퍼와 Python 로더가 멈춤, NUL 줄을 두 로더가 같게 건너뜀, 두 로케일의 유니코드 공백·숫자 거부, 다섯 정수 플래그의 빈 값 거부)는 위 §1–§4 의 내 독립 실험과 같은 결과를 고정한다.
- 기준 실패 주장 재확인: `tests/test_issue10_13_purge.py` 를 이 head 에서 단독 실행 → **23 passed**(1회차의 PR·base 결과와 같음). 이 리뷰어 조건에서는 여전히 재현되지 않는다.
- 격리: 실제 `.mam/jobs` 는 이 잡 자신의 파일 외 추가 0, 검증 경로 tracked 파일 변화 없음(HEAD `0eec795`), 본 체크아웃 `main e1eb22b`.
## 9. 판정
0eec795 는 지적된 네 결함을 정확히 닫고(각각 실험으로 재현→해소 확인), 새 bash 판정을 만들지 않았다(정수 규칙의 문자 집합을 명시하고 플래그 존재를 기록하는 것은 기존 입력 정제의 보강). 버전 락스텝 bump 는 판정에서 제외한다.
| # | 비차단 관찰 | 근거 |
|---|---|---|
| S1 | Python 진입점에서 `EnvFileError` 가 트레이스백으로 나온다 — 한 줄 `ERROR:` + rc 1 로 잡아 주면 래퍼와 같은 모양 | `mqtt_common.py:98-104` → 각 `main` 의 `ensure_env_loaded()`; §5 실측 |
| S2 | 값 없는 `--timeout`(마지막 인자) 은 `unbound variable` rc 1 — 종전 동작이고 실패 방향 | 래퍼 `parse_opts` 의 `shift 2` |
## 10. 교차검증 가이드
```bash
RP=/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261001T140030Z-34381f/1
PY=/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux-wt/pr-43/.venv/bin/python
git -C "$RP" rev-parse HEAD 'HEAD^{tree}' # 0eec795… / 584eac5…
"$PY" -I "$RP/.agents/skills/lib_py/delegation/envfile.py" --shell <권한0000 파일> # rc 1, cannot read (PermissionError)
printf 'A=1\nB=x\0y\nC=3\n' > n.env; "$PY" -I …/envfile.py --shell n.env # export A, C 만 + NUL 경고
LC_ALL=en_US.UTF-8: mam_int $' '30$' ' 1 → 거부 (_positive_int 도 거부)
```
[VERDICT: PASS]
@@ -0,0 +1,67 @@
# PR #58 (fix/issue-57) 병합 전 리뷰 — 이 Mac 정합성 중심 (job b9c7522d)
- 리뷰어: planner-reviewer-claude-01 (Reviewer)
- 리뷰 identity: loop-20261014T152849Z-f1a415 / base e32c9574 .. head 63d81646 (커밋 11개, 21 파일) / tree e8f162f7
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261014T152849Z-f1a415/1` — HEAD·tree 일치, `git status --porcelain` 0 줄, `.mam` 없음(시작·종료 모두). 본 체크아웃 HEAD e32c957(main) 변동 없음.
- 환경: macOS, `/usr/bin/python3` 3.9.6(paho 없음 — 이 Mac 의 PATH `python3` 도 이것), 체크아웃 `.venv` 3.9.6(paho 2.1.0), PATH `bash` = `/bin/bash` 3.2.57.
- 실행 안전: 모든 테스트·탐침은 `env -i`(PATH/HOME/LANG/TMPDIR 만, 필요한 값만 명시)로 브리프의 `-u` 목록 전부를 비웠고 basetemp 는 scratchpad 아래(이름에 "shim" 없음). 수동 재현은 scratchpad 의 임시 워크스페이스·cwd 에서만 했고, base 비교는 `git archive e32c957` 복사본으로 했다. 실제 `.mam`·`.mam.env`·모니터·herdr 서버·에이전트 홈 미접촉.
## 1. 결과 요약
| 항목 | 결과 |
|---|---|
| Python 3.9 문법(`ast.parse(feature_version=(3,9))`, 변경 py 13 개)·`py_compile` | 통과. 3.10+ 문법·API(match, `X \| None`, 내장 제네릭 주석, `removeprefix`, `tomllib`, `zip(strict=)`) 사용 없음 |
| bash 3.2 `bash -n`(lib.sh, 래퍼, worktree_prepare, reconcile, status) | 통과. 래퍼의 `LOGS_DIR_ABS=""` 전역 초기화, `var=$(…) \|\| {…}` 실패 처리, `env_add+=(…)` 모두 3.2 `set -u` 안전 |
| head 관련 모듈 A: issue57·15·20·21_acceptance·21_worktree_prereqs·tier1·17_item4·17_workspace_root_isolation·b7·36 | **477 passed, 2 skipped** |
| head 관련 모듈 B: issue14·18·6_fail_closed·34·33·5_phase1/2a/2b·tier2·12·23_24·21_phase2·**10_13_purge** | **503 passed**(알려진 실패 #28 의 `test_issue10_13_purge` 도 이 Mac 에서 통과) |
| 수정 전 대조군(제품 파일만 base 로 되돌린 복사본) + 새 `test_issue57_logs_follow_registry.py` | **15 failed, 6 errors, 1 passed** — 회귀를 실제로 잡는다 |
| 이전 라운드(ee7cef33) 차단 지적: `test_issue15…:64`, `test_tier1_unit…:512` | 두 곳 모두 `monkeypatch.delenv("MAM_JOBS_DIR")` 추가로 해소, 통과 |
## 2. 맞게 된 부분
- **규칙은 한 곳**: `mqtt_common.sibling_logs_dir`(순수) + `get_logs_dir(registry_dir)`(`DELEGATE_JOB_LOGS_DIR` > 레지스트리 형제). 쓰기·읽기 지점(register·pick·legacy/engine 미러·conflict·publish·subscriber·remote·reconcile·runner·lib.sh·래퍼)이 모두 이 함수를 거친다. lib.sh 의 bash 기본값 사본은 `delegate_logs_dir` 로 대체됐고, 해석 실패 시 감사 확인만 건너뛰고 `events.log` 증거는 유지한다.
- **macOS 경로**: `control_plane._abs` 는 `realpath(expanduser())` 라 `from_env`·행 기록이 모두 `/private/tmp`·`/private/var` 로 정규화된다. `forwarded_keys` 를 직접 탐침: 형제(정규화 후 동일) → 전파 안 함, `/tmp` 와 `/private/tmp` 가 섞인 비정규 입력·후행 `/`·비형제 → 전파. 어긋날 때는 늘 "전파" 쪽(기존 동작)이라 안전하다.
- **디스크 폴백의 재디스패치 안전성**: `job_subscriber._check_disk_fallback` 은 이제 `status.json` 의 `status` 필드를 읽고(이전엔 dict 를 비교해 아무것도 맞지 않았음), 버전 있는 레코드는 감사 `transition_seq` 가 같거나 새로울 때만 채택한다. 이전 시도의 terminal 감사 상태가 새 시도를 끝내지 못하며(`test_old_audit_terminal_does_not_complete_the_redispatched_attempt`), 손상된 seq 는 stale 로 처리한다. 버전 없는 레코드·레코드 없음은 기존 규칙.
- **래퍼**: 16 곳의 `${DELEGATE_JOB_LOGS_DIR:-$WORKDIR/delegate_job_logs}` 제거, 해석 실패 시 fail-closed(exit 1), 해석값을 export 해 자식과 주입 명령이 같은 루트를 쓴다.
- **worktree_prepare / control_plane**: 호출자가 명시한 `DELEGATE_JOB_LOGS_DIR` 만 넘기고, 유도 가능한 형제 루트는 행에만 기록한다. 기존 행 중 레지스트리를 옮겼지만 로그가 워크스페이스에 있던 것은 비형제라 계속 전파되어 resume 짝이 유지된다.
## 3. 차단 지적
**F1. 경로 헬퍼 하나 때문에 create/resume 바인딩과 status 가 paho 를 요구하게 됐다 — 이 Mac 의 문서화된 폴백에서 회귀.**
- `lib_py/control_plane.py:28` 이 `from lib_py.delegation import mqtt_common` 을 새로 하고(base 의 control_plane 은 stdlib 만), `status.sh:171,383` 이 `get_job_status` 안에서 `from lib_py.delegation.mqtt_common import sibling_logs_dir` 를 무조건 한다. `mqtt_common.py:33` 은 모듈 최상단에서 `import paho.mqtt.client` 다.
- 두 경로의 인터프리터는 `_delegate_py_bin`(`lib.sh:2111-2130`): `AGENT_PYTHON_BIN` > 활성 venv > 스킬 위쪽 가장 가까운 `.venv` > **PATH `python3`**. README 도 이 폴백을 문서화한다. 이 Mac 의 PATH `python3` 은 `/usr/bin/python3` 3.9.6 이고 paho 가 없다(install.sh 는 시스템 python3 에 PyYAML 만 요구).
- 재현(임시 워크스페이스, herdr 미호출):
```
AGENT_PYTHON_BIN=/usr/bin/python3 로 lib.sh source 후 mam_control_plane_json <wt>
base: rc=0
head: rc=1 ModuleNotFoundError: No module named 'paho'
status.sh 의 get_job_status 를 그대로 떼어 /usr/bin/python3 로 job 이 있는 행에 실행
base: ('abc12345', 'unknown')
head: ModuleNotFoundError: No module named 'paho'
```
- 영향: 그 폴백에서 `create_session.sh:232-238`·`resume_session.sh:100-106` 은 워크트리 대상이면 바인딩 계산 실패로 **exit 2**(거부), 제어 루트면 `control_plane` 없이 기록한다. 읽기 전용 `status.sh` 는 위임 잡이 걸린 행이 하나라도 있으면 heredoc 이 예외로 죽는다. 둘 다 base 에서는 paho 없이 동작했다.
- 수정(작음): `sibling_logs_dir` 를 paho 를 import 하지 않는 모듈(예: `lib_py/paths.py` 또는 새 `lib_py/delegation/logroot.py`)로 옮기고 `mqtt_common` 은 그것을 재노출, `control_plane`·`status.sh` 는 그 모듈에서 import. (또는 `mqtt_common` 의 paho import 를 지연.) 회귀 테스트로 "paho 없는 인터프리터에서 `control_plane json`·`get_job_status` 가 동작" 을 두면 좋다.
**F2. 래퍼 `logs` 의 기본 경로가 바뀌었다 (레지스트리를 옮기지 않은 평소 실행).**
- `multi-agent-mux-delegate-job` `cmd_logs` 는 이제 항상 `--registry-dir "$reg"` 를 넘기고 `reg` 의 기본은 `REGISTRY_DIR_DEFAULT="${MAM_JOBS_DIR:-.mam/jobs}"`(`:78`) — cwd 상대다. base 는 `--registry-dir` 없이 `registry.py logs` 를 불러 `get_logs_dir()` 이 `$WORKSPACE_ROOT/.mam/delegate_job_logs` 를 썼다(registry.py 자체의 기본 `default=default_registry_dir()`(`:370`) 도 `WORKSPACE_ROOT` 를 존중한다).
- 재현(임시 디렉터리, `WORKSPACE_ROOT=ws` 에 잡 하나 등록, cwd=`elsewhere`):
```
base: logs --list → 그 잡을 나열
head: logs --list → "(no logged jobs under <elsewhere>/.mam/delegate_job_logs)"
```
- 수정(작음): `--registry-dir` 가 주어졌을 때만 넘긴다(이전 라운드 구현처럼 `local reg=()` 배열 + `${reg[@]+"${reg[@]}"}`). 그러면 registry.py 의 기본(`MAM_JOBS_DIR` > `WORKSPACE_ROOT` > cwd)이 그대로 쓰여 기본 경로가 불변이다. "cwd≠워크스페이스에서 `logs`" 테스트 한 건을 권한다.
두 지적 모두 한두 줄 수정이며 설계 변경은 필요 없다.
## 4. 비차단 메모
- `docs/design/worktree-creator-model.md` 의 status 행(“`MAM_JOBS_DIR`·행 `mam_jobs_dir`·감사 로그(`DELEGATE_JOB_LOGS_DIR`·행 `delegate_job_logs_dir`)·기존 후보 순 조회”)에 새로 추가된 "레지스트리 옆 감사 로그" 후보가 빠져 있다.
- `control_plane.forwarded_keys` 는 로그 쪽 값을 정규화 없이 비교한다. 실제 흐름에서는 양쪽이 이미 realpath 라 문제없고 어긋나도 전파 쪽이라 안전하지만, 양쪽에 `_abs` 를 걸면 의도가 분명해진다.
- `cmd_logs` 의 `--registry-dir` 뒤 인자가 없으면 `set -u` 로 "unbound variable" 종료 — fail-closed 이므로 무방.
- 수정 전 대조군의 6 errors 는 base 코드가 테스트가 기대하지 않는 위치에 쓰면서 생긴 setup/teardown 오류로, head 에서는 없다.
## 5. 잔류물
- 복사본·basetemp·탐침 디렉터리는 모두 scratchpad 아래. 검증 경로는 깨끗(`.mam` 없음, `git status` 0 줄). #55 가드 오탐은 이번 실행에서 나오지 않았다.
## 결론
규칙의 일원화, 쓰기·읽기 지점, macOS 경로 정규화, 디스크 폴백의 재디스패치 안전성, bash 3.2·Python 3.9 이식성, 테스트의 회귀 탐지는 모두 맞다. 그러나 (F1) 경로 헬퍼를 paho 를 끌어오는 모듈에서 import 해 이 Mac 의 문서화된 `python3` 폴백에서 create/resume 바인딩과 status 가 깨지고, (F2) 래퍼 `logs` 의 기본 경로가 cwd 상대로 바뀌었다. 둘 다 base 대비 재현됐고 작은 수정으로 끝난다.
[VERDICT: NOT PASS]
@@ -0,0 +1,14 @@
{
"loop_id": "loop-20260926T062809Z-914379",
"base_sha": "af0a2a2557768fa316203b177e1b90e9f7283a1a",
"head_sha": "9200562c914ab4f6e6d8a599cac371a07f99b25d",
"tree_sha": "c8425e2259544c3a34210858f331b0563f15325c",
"review_commit": "9200562c914ab4f6e6d8a599cac371a07f99b25d",
"reviewer": "planner-reviewer-claude-01",
"job_id": "cc418c3b",
"verdict": "PASS",
"transport": "herdr",
"evidence": "review-tree",
"isolation": "worktree",
"verified_at": "2026-09-26T07:07:06Z"
}
@@ -0,0 +1,94 @@
# 🔍 Review Report — PR #41 델타 리뷰 3회차 (job `cc418c3b`)
- **Reviewer**: `planner-reviewer-claude-01` (role: planner,reviewer) · 2026-09-26
- **대상 델타**: `af0a2a2..9200562` (1커밋, 4파일 `+144/−31`, `git diff --check` 클린), base `main 8ea65bb` (v4.7.4).
- **검증 경로**: `/var/folders/.../T/mam-review/loop-20260926T062809Z-914379/1`. **identity 확인**: `HEAD = 9200562c…b25d`, `HEAD^{tree} = c8425e22…325c` — 브리프와 일치.
- **2회차 내 판정**: PASS + P1·P2 (job `2cc309ea`; 1회차 N1 은 그때 닫힘). 이번에는 grok N1(TERM 5 s 예산)·codex D1/D2 의 반영을 본다.
- **안전 규칙 준수**: 검증 경로·소스 워크트리·본 체크아웃(`main 8ea65bb`) 모두 수정·커밋·checkout 없음. 실험은 scratchpad 하네스(`stop_stall_watch` 추출, 대역은 내가 만들고 KILL 로 정리)와 되돌린 사본에서만. 실제 세션에 stall_watch 를 붙이지 않았고 실제 `.mam`·데몬·herdr 서버 조작 없음, push·merge·PR 상태 변경 없음.
## 결론 요약
**세 정정은 지적을 닫는다.** KILL 기반 stop 은 TERM 을 무시하는 대역에 대해서도 **0.00 s** 에 돌아오고(실측), prompt 앞의 마지막 게이트(`_may_prompt`: 래퍼 생존 + strict 재읽기)와 wait 직후의 `_wrapper_gone` 이 D1/D2 의 창을 닫는다. 남는 마이크로초 경합은 레지스트리 락 없이 닫을 수 없고, 그 결과(닫힌 잡에 한 줄 nudge)는 무해하므로 받아들일 수 있다. 비차단 관찰 2건(Q1·Q2).
## 1. grok N1 — KILL 로 바꾼 것 (검토 요청 1)
`multi-agent-mux-delegate-job:711-722` `stop_stall_watch`: `kill -0` → **`kill -9`** → `wait`. 하네스 실측(함수 추출):
| 대역 | 결과 |
|---|---|
| TERM 무시(`trap "" TERM; exec sleep 300`) | rc 0, **0.00 s**, 대역 사망 |
| 정상 | rc 0, 0.00 s |
| `STALL_PID` 비어 있음 | 즉시 return 0 |
| 이미 종료(rc 2) | 즉시, stderr 에 `stall watch for j1 exited rc=2:` + 로그 꼬리(보고 유지) |
**타당하다.** 감시자에 정리할 상태가 없다: 파일·락·소켓을 열어 두지 않고(레코드는 매번 열고 닫음), 유일한 부수효과는 두 하위 프로세스 — `publish_event`(알림, `--attempts 1`, 자체 timeout 120 s)와 `herdr agent prompt`(nudge, herdr 자체 `--timeout`) — 뿐이라 KILL 순간 진행 중이던 것은 각자 끝난다. TERM 핸들러도 `os._exit` 하나였으므로 KILL 로 잃는 동작이 없다. 2회차의 5 s 폴링은 래퍼의 close 시계 앞에 붙어 `deadline_timeout` 을 늦추던 부작용이 있었고(grok 지적), 그 예산이 사라졌다. KILL 은 fork-exec 창에서도 삼켜지지 않는다.
한 가지 기록(Q1): KILL 순간 비행 중이던 `publish_event` 는 래퍼가 잡을 닫은 **뒤** 에 `progress` 를 남길 수 있다. 종결 뒤 이벤트는 등록부 CAS/종단 규칙이 거르거나 한 줄 알림으로 남을 뿐이라 무해하지만, 래퍼의 디스크 liveness 스캔은 `events.log` 의 **모든** 최근 이벤트를 생존 증거로 세므로 감시자의 `stall_watch:*`/`stalled:*` 알림도 idle 유예를 연장한다(벽시계가 상한이라 무한하지는 않다). "멈춤 알림이 생존 증거가 된다"는 이 긴장은 이 델타가 만든 것이 아니며, 후속에서 스캔이 `progress`+`stall_watch:` 접두 detail 을 제외하면 사라진다.
## 2. codex D1/D2 — prompt 앞 게이트 (검토 요청 2·3)
- `_dispatch_open(strict=True)`(`stall_watch.py:266-288`): strict 에서는 읽기 오류도 "아니오". `_may_prompt`(`:296-301`) = 래퍼 생존 ∧ strict 재읽기 — `_notify` 가 **돌아온 뒤**, `prompt()` **직전** 에 호출된다(`:408-412`). 발행 rc 는 상태 판단에 쓰지 않는다(rc 0 은 "그 순간의 CAS 통과" 이지 "지금도 열려 있음" 이 아니다 — 맞는 태도).
- `_wrapper_gone`(`:290-294`)은 루프 머리(`:313`), **매 wait 직후**(`:329`, 어떤 알림·판정보다 먼저), `_may_prompt` 안 — 세 곳. D2 의 "대기 중 부모 소멸 뒤 알림·재주입 1회" 창은 wait 직후 검사로 닫힌다.
- 남는 경합: `_may_prompt` 가 참을 돌려준 뒤 `self.src.prompt()` 의 `herdr agent prompt` 가 실행되기까지의 마이크로초. 이 안에 잡이 닫히면 닫힌 잡의 pane 에 "끝났으면 completed 를 발행하고 아니면 이어서 하라" 한 줄이 들어간다. 에이전트가 그에 따라 `completed` 를 다시 발행해도 등록부가 종단 뒤 종단을 거부하므로 **상태가 바뀌지 않는다**. 레지스트리 락으로만 닫을 수 있는 창이고 비용이 이득보다 크다 — **받아들일 수 있다.**
## 3. 문서
SKILL.md `:51-54`: KILL 즉시, 래퍼 소멸 뒤 최대 wait 한 번(≤ 60 s), 소멸을 본 뒤에는 알림·nudge 없음 — 코드와 일치.
## 4. 테스트 (검토 요청 4 + 검증 방법)
### 4-1. 검증 경로에서의 실행 (브리프의 명령 그대로, pr-41 `.venv`)
```
tests/test_issue30_stall_watch_stop.py tests/test_issue30_stall_watch.py tests/test_issue14_delivery_ack.py
→ 3 failed, 67 passed, 1 skipped in 511.26s (0:08:31)
FAILED test_issue30_stall_watch.py::test_s9_the_wrapper_runs_the_watcher_for_the_jobs_life[1]
FAILED test_issue30_stall_watch.py::test_s10_remote_and_human_jobs_are_not_watched[human-…]
FAILED test_issue14_delivery_ack.py::test_wrapper_idle_timeout_grace_ignores_pane_busy_token
SKIPPED test_issue30_stall_watch.py:331 (S6, /proc)
```
- **stop 테스트 11건(기존 7 + 새 4) 전부 통과.**
- 세 실패는 모두 **공개 브로커 의존**이다. S9[1]·S10[human] 의 실패 지점은 `t18._publish(reg, jid, "started", env).returncode == 0` 이고 stderr 는 `publish failed after 2 attempts: no CONNACK from broker`(4회) — 두 시험은 `_live_broker()` 로 일부러 공개 브로커를 쓴다(`:533-537`). 단독 재실행에서 S9[1]·pane_busy 는 통과했고 S10[human] 만 같은 `no CONNACK` 로 다시 실패했다. 같은 시각 scratch 등록부로 공개 브로커에 `started` 를 세 번 보내자 **1/3 이 21 s 뒤 rc 2** 였다 — 브로커가 이 시간대에 불안정하다는 직접 증거. 이 델타는 human/remote 분기(`start_stall_watch` 의 제외 로직)를 한 줄도 바꾸지 않았고(`git diff … | grep -c 'human\|_is_remote'` = 0), 회차마다 실패하는 케이스가 바뀐다(2회차 S9[0]→R2[False], 3회차 S9[1]→S10). 2회차 P2 그대로.
- **grok 이 인용한 idle-grace 시험의 분류(검토 요청 1 의 참고)**: 동의한다. `test_issue14` 의 `_base_env` 는 브로커를 고정하지 않아 기본값(공개 브로커)을 쓰고, PR 이 인용한 로그(`subscribe handshake timed out`, 등록 후 ~14 s 무배달 → idle 시계 늦게 시작 → 14 s 의 `completed` 가 열린 잡에 들어감)는 감시자와 무관한 구독자 핸드셰이크 지연이다. 이 시험은 PR #29·#37 리뷰 때도 base 와 동일한 코드에서 같은 방식으로 실패·재실행 통과했었고(이 호스트의 T-1 부류), KILL 로 바뀐 뒤에는 감시자가 close 시계 앞에 넣는 예산 자체가 0 이다. 다만 Q1 처럼 감시자의 알림이 디스크 liveness 로 세어지는 경로는 남아 있어, 그 시험이 감시자 켜진 채로 도는 한 "완전히 무관"은 아니다 — 시험 쪽에서 `MAM_STALL_WATCH=0` 을 주거나(감시자는 그 시험의 대상이 아니다) 스캔이 감시자 알림을 제외하면 깔끔해진다(Q2).
### 4-2. 수정 없이는 실패하는가 — 확인함
래퍼와 `stall_watch.py` 만 2회차 head(`af0a2a2`)로 되돌린 사본에서 `test_issue30_stall_watch_stop.py` → **5 failed / 6 passed (33.8 s)**: (a) `returns_at_once`(2회차 코드는 5 s 예산으로 took≈6), 그리고 새 4건(발행 중 completed / 재디스패치 / 깨진 레코드 / 대기 중 래퍼 소멸) 이 정확히 실패. 잔류 프로세스 0.
### 4-3. 결정성·잔류 (검토 요청 4)
새 4건은 in-process(`wait_once` 주입, `_notify` 는 발행 중 다른 행위자가 레코드를 바꾸는 대역, `_alive` 주입)라 브로커·herdr·시계에 의존하지 않고 3회 실행(전체·되돌린 사본·단독)에서 결과가 같았다. 세 실행 뒤 호스트에 `stall_watch --job`/`sleep 300` 잔류 **0**. 실제 `.mam/jobs` 는 이 잡 자신의 파일 외 **추가 0**, 검증 경로 tracked 파일 변화 없음(HEAD `9200562`), 본 체크아웃 `main 8ea65bb`.
## 5. 2회차 판정 항목의 처리
| # | 2회차 항목 | 이번 델타 |
|---|---|---|
| (1회차 N1) 래퍼 영구 정지 | 2회차에 닫힘 | 유지 — 이제 KILL 즉시(0.00 s 실측) |
| grok N1 | TERM 5 s 예산이 close 시계를 늦춤 | **닫힘** — 예산 제거 |
| codex D1 | 알림 발행 중 상태 변화에도 prompt | **닫힘** — `_may_prompt`(strict 재읽기 + 래퍼 생존), 테스트 3건 |
| codex D2 | 대기 중 부모 소멸 뒤 알림·재주입 | **닫힘** — wait 직후 `_wrapper_gone`, 테스트 1건, SKILL.md 문구 |
| P1 | 부모 소멸 감지 지연의 상태별 상한(정보) | 문서에 "≤ 60 s" 로 명시됨 — 종결 |
| P2 | S9/R2(이제 S10 도) 의 공개 브로커 의존 | 열림 — 이번 실행에서도 3건 |
## 6. 판정
세 정정은 지적을 닫고 부작용이 없다(KILL 로 잃는 상태 없음, 게이트가 prompt 의 유일한 경로 앞에 있음). 남는 마이크로초 경합은 결과가 무해해 받아들인다. 버전 락스텝 bump 는 판정에서 제외한다.
| # | 비차단 관찰 | 근거 |
|---|---|---|
| Q1 | 래퍼의 디스크 liveness 스캔은 `events.log` **마지막 줄의 timestamp** 만 보므로 감시자의 `progress`(`stall_watch:*`/`stalled:*`) 도 생존 증거가 된다 — 멈춤 알림이 idle 유예를 연장하는 긴장(벽시계가 상한) | 래퍼 `:810-825` 의 스캔; 델타 이전부터 |
| Q2 | `test_issue14` 의 liveness 시험들은 감시자를 끄지 않고 돈다 — 시험 대상이 아니므로 `MAM_STALL_WATCH=0` 을 주는 편이 결정적 | `_base_env` 에 `MAM_STALL_WATCH` 없음 |
## 7. 교차검증 가이드
```bash
RP=/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20260926T062809Z-914379/1
PY=/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux-wt/pr-41/.venv/bin/python
git -C "$RP" rev-parse HEAD 'HEAD^{tree}' # 9200562… / c8425e2…
# stop_stall_watch 추출 → TERM 무시 대역: rc 0, 0.00 s, 대역 사망 (§1)
# 되돌린 사본(래퍼·stall_watch.py 를 af0a2a2 판으로) → tests/test_issue30_stall_watch_stop.py → 5 failed / 6 passed
# 브로커 건강: scratch 등록부에 started 3회 발행 → 1/3 rc 2 (no CONNACK) — S9/S10 실패와 같은 원인
```
[VERDICT: PASS]
@@ -0,0 +1,45 @@
# 리뷰 리포트 — Job cd288bf2 (Issue #17 후속 2회차: 상속 WORKSPACE_ROOT, 6b00b6e..8b47cc7)
- 리뷰어: planner-reviewer-claude-01 (claude)
- 리뷰 identity: loop-20261008T133858Z-9e7f5b / base 6b00b6e / head 8b47cc7 / tree bfe5fcb — 검증 경로(…/2)에서 `git rev-parse HEAD 'HEAD^{tree}'` 일치, 미커밋 변경 없음
- 범위: 커밋 2개(d198dd5 = 1회차, 8b47cc7 = grok·codex 지적 반영). conftest.py 의 `own_workspace_root` fixture + 테스트 파일 14개 + 새 tests/test_issue17_workspace_root_isolation.py(W1·W2). 제품 코드(.agents/skills)·버전·VERSIONS 변경 없음(브리프대로).
## 결론 요약
설계(opt-in fixture `own_workspace_root`, W1 가 모듈별 대표 노드를 체크아웃 사본에서 돌려 `.mam` 미생성을 고정, W2 가 test_orc_onboard 의 import 시점 root 전파를 고정)는 적절하고, 수정된 14개 파일·회귀 테스트·(a)(b) 실행은 모두 요구대로다. 그러나 **조사(2)가 모듈 하나를 놓쳤다**: tests/test_o2_race_free_lock.py 의 `test_o2_12_run_loop_exits_on_lock_failure` 는 HEAD 에서도 자신이 도는 체크아웃의 `.mam/shim/herdr` 를 쓴다(§3). 이 커밋의 제목("every test that sources lib.sh does it under its own WORKSPACE_ROOT")과 W1 의 전제("one test per module that sourced lib.sh … with the inherited root")에 어긋나는 같은 결함이며, 브리프가 리뷰어에게 직접 grep 으로 확인하라고 한 항목이다. 수정은 두 줄(§3)이고 재설계는 아니므로 ESCALATE 는 아니다.
## 1. 할 일 1·3·4 — 요구대로
- `_env`(test_issue17_item4_decisions.py:224-233): 1회차 그대로(제외 + tmp_path 지정). lib.sh:26 이 미설정 시 체크아웃을 유도하므로 "지정"이 맞다.
- 회귀 테스트: 1회차의 test_c_helpers_write_nothing_to_an_inherited_workspace 에 더해 W1·W2(새 파일). **수정 전 코드에서 실패 확인**:
- W1 상당: base(6b00b6e) 트리 사본 안에서 W1 의 NODES 13개를 `WORKSPACE_ROOT=<사본>` 으로 실행(24 passed) → 사본에 `.mam/shim/herdr` 생성됨(W1 의 `written == []` 단언이 실패하는 조건).
- W2 상당: base 의 test_orc_onboard 를 `WORKSPACE_ROOT=<센티널>` 로 import 한 뒤 `run_find_uuid(ws, …)` 호출 → **센티널**에 `.mam/shim/herdr` 생성, ws 에는 없음(import 시점 root 전파 재현). HEAD 의 `_scrubbed_env(root)`(test_orc_onboard.py:24-29)는 호출 시점 root 를 쓴다.
- (a)(b) 실행(검증 경로, 워크트리 .venv Python 3.9.6, `env -i` 로 HERDR_*·MAM_*·MQTT_* 제거):
| 실행 | 결과 |
|---|---|
| (a) WORKSPACE_ROOT 없이: 수정 14개 파일 + test_issue33_budget, test_issue5_phase1_delegation, test_tier1_unit | **643 passed / 0 failed** (815 s) |
| (b) WORKSPACE_ROOT=<워크트리 밖 센티널>: 수정 14개 파일 | **490 passed / 0 failed** (656 s); 실행 뒤 센티널 **비어 있음** |
| 본 체크아웃 .mam/shim/herdr mtime | 전후 동일(1791473441); 실제 .mam/jobs 항목 수 불변 |
- 이식성: Python 테스트 코드만(3.9 문법); W1 은 `git ls-files` 와 nested pytest 로 이 Mac 에서 통과.
## 2. 할 일 2 — 설계 판단
- conftest 의 `pytest_configure`(:139-148)는 **다른** 체크아웃을 가리키는 WORKSPACE_ROOT 만 지우고 이 체크아웃을 가리키면 남긴다(lib.sh 가 어차피 유도하는 값). 위험은 "이 체크아웃 자신"(에이전트 pane 에서 main 체크아웃으로 스위트를 돌릴 때 = 실제 워크스페이스)이며, 그 근본은 lib.sh:3088 이 **source 시점마다** `_init_herdr_isolation`(:143-146) 으로 `$WORKSPACE_ROOT/.mam/shim/herdr` 를 쓰는 것이다(직접 확인: `source lib.sh && find_workspace_uuid` 만으로 센티널에 shim 생성).
- `own_workspace_root` 를 autouse 로 두지 않은 이유(docstring: runner.py·mqtt_common 이 in-process 로 WORKSPACE_ROOT 를 읽음)는 타당하고, usefixtures 마크는 autouse 뒤·테스트 인자(mam_sandbox) 앞에 적용되므로 "mam_sandbox 와 테스트 자신의 지정이 이긴다"는 주장도 맞다. unittest.TestCase(test_workspace_scope)는 마크가 적용되지 않아 env 를 직접 지정한 것도 일관된다.
- test_issue21_worktree_prereqs `_mutate`(:546-547)와 test_workspace_scope(:30-32)의 명시 지정, test_orc_onboard 의 `_scrubbed_env` 도 적절.
## 3. 놓친 모듈 (차단) — tests/test_o2_race_free_lock.py:196-203
```
196 def test_o2_12_run_loop_exits_on_lock_failure(tmp_path):
200 cmd = ["bash", str(RUN_LOOP), "--creator", "dummy-agent", "--task", "test"]
201 run_env = dict(os.environ)
203 res = subprocess.run(cmd, …, cwd=str(tmp_path), env=run_env)
```
- run_loop.sh:17 이 lib.sh 를 source 하고, 이 테스트는 os.environ 을 그대로 물려주며(WORKSPACE_ROOT 미지정, cwd 만 tmp_path), 파일에 `own_workspace_root` 마크도 mam_sandbox 도 없다(:5 `import pytest` 는 이미 있음).
- 재현(W1 과 같은 방법, HEAD 트리 사본): `WORKSPACE_ROOT=<사본>` 으로 이 테스트 1개 → 사본에 `.mam/shim/herdr` **생성**; `WORKSPACE_ROOT` 미설정으로도 **생성**(lib.sh 가 체크아웃을 유도); 파일 전체(22 passed)도 생성. 같은 방법으로 돌린 내 다른 후보 11개 파일 40개 테스트(test_issue30_stall_watch_stop, test_issue36_prompt_transport, test_issue19, test_a4, test_issue21_acceptance, test_issue20, test_issue34, test_issue23_24, test_b4, test_issue15, test_issue10_13)는 모두 깨끗했다 — 놓친 것은 이 모듈뿐이다.
- 따라서 W1 의 "every module whose shells used to inherit the root" 목록(NODES, test_issue17_workspace_root_isolation.py:26-40)이 불완전하고, 커밋 8b47cc7 의 제목이 참이 아니다.
- 수정(같은 방식): test_o2_race_free_lock.py 에 `pytestmark = pytest.mark.usefixtures("own_workspace_root")` 추가(o2_13 의 mam_sandbox 는 그대로 이김), W1 NODES 에 `tests/test_o2_race_free_lock.py::test_o2_12_run_loop_exits_on_lock_failure` 추가. 그러면 W1 이 이 회귀를 앞으로 잡는다.
## 4. 비차단
- W1 은 모듈당 대표 노드 1개만 돌리므로 같은 모듈의 다른 테스트가 새로 lib.sh 를 source 하면 잡지 못한다 — 마크가 모듈 전체에 걸리므로 현재 설계에서는 문제없지만, 목록 유지가 수동이라는 한계는 적어 둔다.
- 1회차 표의 두 파일(test_b19, test_version_consistency)은 이번에 마크로 덮였다.
[VERDICT: NOT PASS]
@@ -0,0 +1,14 @@
{
"loop_id": "loop-20261004T044643Z-1b1440",
"base_sha": "e1ed2708d835d712f5f3a9dfd51b8f66ca1ac447",
"head_sha": "150b4937f816c6407be16fc4c37ab88e478927e7",
"tree_sha": "afce44d2e0f91720d81e8d7d484f0e1358dee680",
"review_commit": "150b4937f816c6407be16fc4c37ab88e478927e7",
"reviewer": "planner-reviewer-claude-01",
"job_id": "dcece792",
"verdict": "PASS",
"transport": "herdr",
"evidence": "review-tree",
"isolation": "worktree",
"verified_at": "2026-10-04T04:54:16Z"
}
@@ -0,0 +1,37 @@
# 리뷰 리포트 — Job dcece792 (PR #47 2회차 델타, e1ed270..150b493)
- 리뷰어: planner-reviewer-claude-01 (claude)
- 리뷰 identity: loop-20261004T044643Z-1b1440 / base e1ed270 / head 150b493 / tree afce44d — 검증 경로에서 `git rev-parse HEAD 'HEAD^{tree}'` 일치, 미커밋 변경 없음
## 결론 요약
codex R1(그리고 같은 내용인 본인 1회차 관찰 N1)이 닫혔다. 차단 결함 없음.
## 1. 범위
`git diff --stat e1ed270 HEAD` 와 `git diff --stat 6af4a79 HEAD` 모두 tests/test_issue42_skill_blocks.py 한 파일(누적 +59/−3). 제품 코드·버전 변경 없음.
## 2. R1 종결
| 지적 | 수정 (tests/test_issue42_skill_blocks.py) | 확인 |
|---|---|---|
| codex R1 / claude N1: 깨진 최상위 링크가 `Path.exists()` 때문에 skip | claude_skills_problems(:240-261): 부재 = `not exists() and not is_symlink()` 일 때만 None(skip). symlink 는 `exists() and resolve() == skills_dir.resolve()` 여야 함 | 검증 경로에 `.claude/skills -> ../nowhere` 를 만들어 실제 테스트 실행 → **fail** (1회차 head 에서는 skip 이었음) |
## 3. 실제 배치 탐침 (검증 경로 안 임시 .claude/skills, 매번 삭제)
| 배치 | 결과 |
|---|---|
| 스킬별 링크 8개 + lib.sh·lib_py 링크(본 체크아웃과 동일) | pass |
| 단일 링크 → ../.agents/skills | pass |
| 깨진 최상위 링크(codex 탐침) | fail |
| 스킬 링크 하나가 깨짐 | fail ("… missing or dangling") |
| .claude/skills 가 일반 파일 | fail (각 스킬 "missing or dangling") |
| 최상위 자기 참조 링크(`skills -> skills`) | fail (RuntimeError: Symlink loop, Python 3.9.6) — 관찰 참고 |
시작 전 .claude 부재, 끝난 뒤 잔여물 없음·`git status --short --ignored` 비어 있음·HEAD 불변 확인. 본 체크아웃의 .claude/skills 는 건드리지 않았다.
## 4. 합성 배치 테스트
- test_claude_skills_layouts(:271-300)는 실제 배치 검사(test_claude_skills_link_resolves_to_agents_skills, :264-268)와 **같은 함수** claude_skills_problems 를 부른다.
- tmp_path 아래 상대 링크만 쓰고 외부 상태·시간·네트워크에 의존하지 않으므로 결정적이다. 6개 배치(absent/single/per_skill/wrong_skill/broken_top/broken_entry)가 모두 워크트리에서도 돈다.
- 실행: 검증 경로에서 `tests/test_issue42_skill_blocks.py` **37 passed / 1 skipped** (1회차 31 + 합성 6; skip 은 실제 배치 테스트, 워크트리라 정상).
## 5. 비차단 관찰
- 최상위 링크가 순환하면 :252 의 메시지용 `link.resolve()` 가 Python 3.9 에서 RuntimeError 를 던져 assert 실패 대신 예외로 끝난다. 어느 쪽이든 테스트는 실패하므로 잘못된 배치를 놓치지 않는다. 메시지를 `os.readlink(link)` 로 만들면 깔끔해진다(선택).
- 복사본 디렉터리일 때 메시지가 원인을 직접 말하지 않는 점(1회차 N2)은 그대로이며 사소하다.
[VERDICT: PASS]
@@ -0,0 +1,93 @@
# PR #62 리뷰 — Issue #60 Linux 확인 후속 (job dd5c8c62)
- 리뷰어: planner-reviewer-claude-01
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261019T000805Z-975c4c/1`
- identity: HEAD `6a4e385a78c8a561d5cf3644336de6a352ea9271`, tree `afa2c55925f93820337b69699c670c0b0ca90980` 일치. 작업 트리 clean, `.mam` 없음. base `1d7b7da`..head 8커밋. 판정은 base..head 전체에 대한 것이다.
- 환경: macOS, `/usr/bin/python3` 3.9.6 와 `.venv` python 3.9.6, 둘 다 sqlite 3.51.0. 실제 opencode 데이터는 `ls`·`grep -c` 로만 봤다. 실제 DB 는 sqlite 로 열지 않았다.
## 결론
제품 코드에서는 차단 결함을 찾지 못했다. 다만 새 테스트 5개가 이 Mac 에서 항상 실패한다(B1). 이 리뷰의 중점이 macOS 정합성이고, 오케스트레이터의 전체 스위트도 이 Mac 에서 돈다. 그래서 NOT PASS 로 둔다. 고칠 곳은 fixture 몇 줄이고 재설계는 필요 없다.
## 차단
**B1. `test_a_query_reads_right_and_leaves_the_sidecars_alone[clean-*]` 5개가 macOS 에서 실패한다.**
- 위치: `tests/test_issue60_linux_fixes.py:153-154`(`make("clean")`)과 `:175-181`.
- 원인: macOS 시스템 SQLite 는 persistent WAL 로 동작해서, 마지막 연결을 닫아도 `-wal`·`-shm` 을 지우지 않는다. 임시 DB 에서 쓰기 연결을 닫은 뒤의 목록이 `['o.db', 'o.db-shm', 'o.db-wal']` 였다. 그래서 `_wal_writer(db, ws).close()` 뒤의 목록이 기대값 `["opencode.db"]` 와 다르고, 첫 assert(`make(state) == listing`)에서 실패한다. 제품 코드까지 가지도 않는다.
- 재현: 검증 경로에서 `env -i …` 로 `tests/test_issue60_linux_fixes.py` 를 단독 실행하면 5 failed, 40 passed. 관련 18개 모듈 실행(아래)에서도 같은 5개만 실패했다.
- 제품 코드는 맞다. 이 테스트의 `clean` 상태는 Linux 에서 닫힌 DB 의 모습(사이드카 없음)이다. git archive 사본에서 이 상태를 만들도록 close 뒤에 `-wal`·`-shm` 을 지워 보았다. 그러자 해당 서브셋 33개가 모두 통과했고, 아래 뮤테이션 6개도 모두 잡혔다.
- 권고: `clean` 분기에서 close 뒤 남은 `-wal`·`-shm` 을 지운다. close 가 이미 checkpoint 하므로 행은 본 파일에 있다. 또는 Mac 의 실제 상태인 "쓰는 쪽 없이 사이드카 둘 다"는 이미 `leftover` 가 다루므로, 그 사실을 주석으로 남긴다.
- 주의: 지금 상태의 Mac 에서는 `clean` 이 첫 assert 에서 멈춘다. 그래서 immutable 분기를 없애는 뮤테이션(M1)이 이 Mac 에서는 드러나지 않는다.
## 중점 항목 확인
**1. `_ro_connect` 세 갈래 (`opencode.py:13-36`).** 검증 경로 코드를 3.9 로 import 해 임시 DB 에서 확인했다. 사이드카를 직접 지워 Linux 와 같은 상태를 만들었다.
| 상태 | 결과 |
|---|---|
| 사이드카 없음 | 같은 상태에서 base 의 평범한 `mode=ro` 는 `unable to open database file` 로 실패한다. 그래서 base 의 `_descendants`·`db_rows_naming` 은 Mac 에서도 빈 결과였다. `_ro_connect`(immutable)는 행을 읽고 아무 파일도 만들지 않는다. |
| writer 가 살아 있음(둘 다 있음) | WAL 에만 있는 행까지 읽는다. 목록은 그대로다. |
| `-shm` 없는 `-wal` | 커밋된 행을 읽는다. 원본 두 파일의 mtime 은 그대로였고 `-shm` 도 생기지 않았다. 같은 상태에서 평범한 `mode=ro` 는 `-shm` 을 만들었다. 디렉터리가 읽기 전용(0555)이어도 읽힌다. |
| DB 없음 | `OperationalError`. 파일은 생기지 않는다. base 의 `sqlite3.connect(db)` 는 빈 DB 를 만들었으므로 개선이다. |
- 실제 Mac 의 상태: 실제 디렉터리에는 서비스가 떠 있어 `opencode.db`·`-shm`·`-wal` 이 모두 있다(`ls`). persistent WAL 이 기본인 Mac 에서는 "둘 다 있음 → mode=ro" 갈래가 대부분일 것이다. 다만 bun 이 시스템 SQLite 를 쓰는지는 확인하지 않았다.
**2. `_DB_PATHS` 캐시 (`opencode.py:39-40, 139-161`).**
- 키는 `(bin, ctx.home_dir, _data_home(ctx))` 다. `_run` 이 넘기는 HOME·XDG_DATA_HOME 과 같은 값이라, 다른 HOME 이나 XDG 에 다른 경로를 돌려주지 않는다.
- 실패한 `debug paths` 는 저장되지 않는다. 바이너리가 없으면(`bin_path` None) 캐시를 쓰지 않는다.
- 테스트 간 누출: 이 모듈은 autouse fixture 로 캐시를 비운다. 다른 모듈은 tmp_path 별 HOME 이라 키가 겹치지 않는다. 18개 모듈을 한 프로세스에서 실행했을 때 B1 외의 실패는 없었다.
**3. auth_ok (`opencode.py:317-321`, `__main__.py:120-127`, `create_session.sh:145-151`).**
- 다른 에이전트의 분기와 adapter 는 바뀌지 않았다. `auth-ok` 를 부르는 곳은 opencode 분기뿐이다.
- `python -m lib_py.agents auth-ok opencode` 를 `/usr/bin/python3` 3.9 로 실행했다. PATH 에 opencode 가 없으면 rc 1, `~/.opencode/bin` 이 있으면 rc 0, 인자가 없으면 rc 1(`unknown agent ''`).
- PYTHONPATH 는 lib.sh 가 export 한다(`lib.sh:25`). 같은 파일의 `facts`·`spawn-spec` 호출과 같은 방식이다.
- create 의 사전 검사로 충분하다. 바로 위 `command -v "$AGENT"`(`:117`)가 이미 PATH 의 바이너리를 요구한다.
**4. `input_placeholder_pattern` (`opencode.py:75-79`, `input_region.py:83-85`).**
- 홈 화면 fixture 두 개(140열, 잘린 예시가 있는 60열)는 모두 빈 입력으로 읽힌다. 1.x 캡처도 그대로 빈 입력이다. 다른 adapter 는 None 이다(테스트로 고정).
- fullmatch 라 `Ask anything… "x" and fix it` 처럼 뒤에 글이 붙은 입력은 그대로 읽힌다.
**5. stop 문구 (`stop_session.sh:242`).** 메시지만 바뀌었고 동작은 같다. 테스트 2개(exit 됨 / 살아 있어 kill)가 통과했다.
**6. Python 3.9 와 셸.**
- 바뀐 Python 5개 파일이 3.9 에서 파싱되고, 제품 모듈은 3.9 로 import 해 실행했다. 새 테스트는 `from __future__ import annotations` 를 쓴다.
- `create_session.sh`·`stop_session.sh` 가 `/bin/bash -n`(3.2)을 통과한다. `git diff --check 1d7b7da..HEAD` 는 깨끗하다.
## 테스트 실행 (검증 경로)
조건: `env -i`, 임시 HOME, `XDG_DATA_HOME=<scratchpad>`, basetemp 는 scratchpad, `-p no:cacheprovider`, `PYTHONDONTWRITEBYTECODE=1`.
대상 18개 모듈: issue60_linux_fixes, issue60_opencode_v2, a4, issue50_dispatch, issue30_stall_watch(+_stop), issue31, tier1, tier2, c1_tui_readiness, issue5_phase0/phase2b, issue24, issue6, uuid_target, issue55, issue57, issue21_worktree_prereqs.
- 결과: **5 failed(B1 의 5개뿐), 623 passed, 2 skipped**, 29분.
- skip 2개: `/proc` 전용 테스트와, PATH 에 opencode 가 없어 건너뛴 실바이너리 계약 테스트.
- 부작용 확인:
- scratchpad XDG 디렉터리는 실행 뒤에도 비어 있었다.
- 실제 `~/.local/share/opencode` 목록은 실행 전후가 같았다.
- 실제 log 의 `debug` 줄 수는 2 → 2.
- 남은 opencode 프로세스는 없었다(사용자 서비스 제외).
- #55 가드 오탐도 없었다.
뮤테이션: git archive 사본에 B1 fixture 수정을 넣고 진행했다. 대상은 issue60_linux_fixes 와 a4 이며, 실제 프로세스를 띄우는 stop·create 2개는 뺐다. 모두 잡혔다.
| 뮤테이션 | 결과 |
|---|---|
| M1 immutable 분기를 `mode=ro` 로 | 5 failed |
| M2 `wal and shm` 를 `wal` 로 | 5 failed |
| M3 캐시 사용 끔 | 1 failed |
| M4 잘린 예시 허용(`"?`) 제거 | 2 failed |
| M5 fullmatch 를 match 로 | 4 failed |
| M6 auth_ok 를 항상 True 로 | 3 failed |
## 비차단
- **N1. immutable 경쟁.** `-wal` 없음을 판단한 뒤 연결이 쓰이는 동안 writer 가 생기면 결과가 틀릴 수 있다. 임시 DB 에서 재현했다.
- (a) 판단 직후 writer 가 한 행을 커밋하면, immutable 연결은 1/2 만 본다.
- (b) 열린 immutable 커서 아래에서 writer 가 지우고 다시 쓰고 checkpoint 하면, 예외 없이 옛 행과 새 행이 섞인 20119행이 나왔다(옛 20000행, 새 30000행).
- 쿼리가 짧아(LIMIT 20, 단건 조회) 실제 창은 ms 수준이다. persistent WAL 인 Mac 에서는 이 갈래 자체가 드물다.
- 원하면 이렇게 줄일 수 있다: 쿼리 뒤 `-wal` 이 여전히 없고 본 파일의 mtime·size 가 그대로인지 확인하고, 아니면 `mode=ro` 로 한 번 다시 읽는다.
- **N2. 캐시는 프로세스 수명 동안 유지된다.** 같은 경로에서 바이너리를 업그레이드한 경우, 오래 도는 `reconcile --subscribe` 는 예전 `debug paths` 결과를 계속 쓴다. 경로 규칙이 바뀌는 업그레이드에서만 문제가 된다.
- **N3. 사전 검사가 약해졌다.** create 의 opencode 검사는 사실상 `command -v` 와 같아졌다. 깨진 바이너리도 통과하고, 실패는 이후 준비 대기 단계에서 드러난다. 2.x 에 로그인이 없다는 전제와 맞는 완화로 본다.
- **N4. 안내 문구와 같은 모양의 초안.** 사용자가 정확히 그 모양으로 친 입력(`Ask anything...`, `Ask anything… "fix the tests"`)은 빈 입력으로 읽힌다. 확인한 결과 둘 다 `''` 였다. 실사용에서 생길 가능성은 매우 낮다.
[VERDICT: NOT PASS]
@@ -0,0 +1,14 @@
{
"loop_id": "loop-20260923T220355Z-76dd1d",
"base_sha": "f4ba205c9f755d817e2206c7a9cb390a3e61fd2f",
"head_sha": "05b0ea4762de3fad013e8986d45db21c4e6aa465",
"tree_sha": "8f5e8b0402017d2cc809364e60400c4c25b922f3",
"review_commit": "05b0ea4762de3fad013e8986d45db21c4e6aa465",
"reviewer": "planner-reviewer-claude-01",
"job_id": "df626811",
"verdict": "PASS",
"transport": "herdr",
"evidence": "review-tree",
"isolation": "worktree",
"verified_at": "2026-09-23T23:35:19Z"
}
@@ -0,0 +1,99 @@
# 🔍 Review Report — PR #35 델타 리뷰 2회차 (job `df626811`)
- **Reviewer**: `planner-reviewer-claude-01` (role: planner,reviewer) · 2026-09-24
- **대상 델타**: `f4ba205..05b0ea4` (1커밋 `05b0ea4`, 6파일 `+76/−5`, `git diff --check` 클린), base `main 1e78dae` (v4.7.1).
- **검증 경로**: `/var/folders/.../T/mam-review/loop-20260923T220355Z-76dd1d/1`. **identity 확인**: `HEAD = 05b0ea47…a465`, `HEAD^{tree} = 8f5e8b04…22f3` — 브리프와 일치.
- **1회차 내 판정**: PASS (job `adc0ff3c`). 이번에는 codex 탐침 2건의 정정이 실제로 닫혔는지와 부작용을 본다.
- **안전 규칙 준수**: 검증 경로·소스 워크트리·본 체크아웃(`main 1e78dae`) 모두 수정·커밋·checkout 없음. 함수 추출 실험과 되돌린 사본 실행은 scratchpad 에서만, pytest 는 `AGENT_PYTHON_BIN` + `--basetemp=<검증 경로 밖>`. 실제 herdr·세션·`.mam`·데몬 조작 없음, push·merge·PR 상태 변경 없음.
## 결론 요약
**두 정정은 탐침을 닫고, 1회차 판정을 뒤집을 요소는 없다.** P2 의 `continue` 는 S5/S5b 의 쿨다운 계약을 그대로 유지하면서 같은 화면의 다음 다이얼로그를 보게 하고, P1 의 `AUTO_TRUST="${MAM_AUTO_TRUST:-1}"` 는 "플래그 > 환경 > 기본 1" 의 우선순위를 문서대로 만든다. 비차단 관찰 2건(G1·G2).
## 1. P2 — `_mam_dialog_pick` 의 `continue` (항목 1)
`lib.sh:2954-2958`. 함수만 추출해 codex 어댑터의 다이얼로그 2개(신뢰 창 idx0 → `Enter`, 업데이트 안내 idx1 → `2`)와 **두 창이 함께 있는 화면**으로 7가지 상태를 돌렸다(cooldown 5 s):
| # | 상태 | 결과 | 의미 |
|---|---|---|---|
| 1 | 기록 없음, 자동 신뢰 on | `send\|0\|Enter` | 신뢰 창이 먼저(어댑터 순서) |
| 2 | 신뢰 창을 1 s 전에 보냄(쿨다운 중), on | **`send\|1\|2`** | **P2 정정**: 쿨다운 중인 창을 건너뛰고 안내에 답한다. 옛 코드는 여기서 `exit 1` 로 아무것도 안 했다 |
| 3 | 기록 없음, 자동 신뢰 off | `skip\|0\|` | 신뢰 창은 사람 몫, skip 기록 |
| 4 | skip 1 s 뒤, off | **`send\|1\|2`** | **P2 정정**: skip 의 쿨다운이 안내를 가리지 않는다 |
| 5 | 두 창 모두 쿨다운 중 | 없음(rc 1) | **S5 유지**: 쿨다운 안에서는 재전송 없음 |
| 6 | 두 창 모두 쿨다운 만료(+5 s) | `send\|0\|Enter` | **S5b 유지**: 만료 후 1회 재시도, 다시 신뢰 창부터 |
| 7 | off, 만료 | `skip\|0\|` | 경고가 쿨다운마다 1회 |
**키 순서·횟수**: 핸들러는 한 폴링에 하나만 고르므로(`_mam_dialog_pick` 은 첫 actionable 항목에서 종료), 두 창이 한 화면에 있으면 **1회차 폴링에 `Enter`, 다음 폴링(1 s 뒤)에 `2`** 순으로 각 1회 간다. S5f 가 `["Enter", "2"]` 를, S6c 가 `["2"]` + 경고를 그대로 고정한다. 쿨다운은 **인덱스별**(`state[idx]`)이라 서로 간섭하지 않는다.
## 2. P1 — `AUTO_TRUST="${MAM_AUTO_TRUST:-1}"` (항목 2)
`create_session.sh:72,87-88,96` 과 `resume_session.sh:33,42-43,57` 이 같은 3단 구조(초기값 ← 환경, 플래그 arm 이 덮어씀, 마지막에 `export`)다. 세 줄을 그대로 옮긴 하네스로 8가지를 돌렸다:
| 환경 `MAM_AUTO_TRUST` | 플래그 | 핸들러가 받는 값 | 판정 |
|---|---|---|---|
| 없음 | – | `1` (on) | 기본 |
| `0` | – | `0` (**off**) | **P1 정정**: 문서의 환경변수 형식이 이제 닿는다(옛 코드는 `1` 로 덮어썼다) |
| `0` | `--auto-trust` | `1` (on) | 플래그 > 환경 |
| `1` | `--no-auto-trust` | `0` (off) | 플래그 > 환경 |
| 없음 | `--no-auto-trust` | `0` (off) | |
| `yes` / `false` | – | 그대로 전달 → 핸들러 `!= "0"` 규칙으로 **on** | `_mam_dialog_pick` 도 같은 규칙(`MAM_DLG_TRUST != "0"`)이라 두 층이 일치한다 |
| `''`(빈 문자열) | – | `:-1` 이 빈 값을 미설정으로 봐 `1` (on) | 핸들러의 `${MAM_AUTO_TRUST:-1}` 와 같은 해석 |
즉 "0 만 off, 나머지는 전부 on" 이 스크립트·핸들러·picker 세 층에서 동일하다(`lib.sh:2986`, picker 의 `!= "0"`). create SKILL.md `:119-120` 이 환경변수 형식과 "플래그 우선"을 명시한다. **G1 (비차단)**: `false`/`no` 가 on 으로 해석되는 것은 일관되지만 직관에 반한다 — 문서에 "`0` 만 끈다" 를 한 마디 적어 두면 좋다. resume SKILL.md 는 `:107` 에서 `MAM_AUTO_TRUST=0` 형식은 적지만 "플래그가 환경보다 우선" 은 create 쪽에만 있다(G2, 사소).
## 3. 테스트 (항목 1·3)
### 3-1. 수정 없이는 실패하는가 — 확인함
검증 경로 사본에서 `lib.sh`·`create_session.sh`·`resume_session.sh` 세 파일만 `f4ba205` 판으로 되돌리고 새 회귀와 이웃 시험을 `-k "s6c or s5f or r3b or c3 or test_s5_ or s5b or test_s6_"` 로 돌렸다:
```
4 failed, 7 passed, 70 deselected in 153.65s
FAILED test_s6c_a_skipped_trust_dialog_does_not_hide_the_notice_below_it ← P2
FAILED test_s5f_a_dialog_inside_its_cooldown_does_not_block_the_next_one ← P2
FAILED test_r3b_…_on_resume[0-flag0-0] (환경변수만, 플래그 없음) ← P1
FAILED test_c3_…_on_create[0-flag0-0] (환경변수만, 플래그 없음) ← P1
```
실패 4건이 정확히 두 탐침의 재현 사례다. 통과한 7건 — R3b/C3 의 "플래그가 환경을 이긴다" 4사례(플래그는 옛 코드에서도 동작했다), S5·S5b(쿨다운 계약), S6(신뢰 창 skip + 안내 응답, 한 화면에 하나씩) — 은 정정 전후로 **변하지 않아야 하는** 계약이고, 실제로 변하지 않았다. 즉 `continue` 가 S5/S5b 를 깨지 않았다는 것이 되돌린 사본과 PR 트리 양쪽에서 성립한다(PR 트리 쪽은 §3-2).
### 3-2. PR 트리(검증 경로)에서의 실행
```
tests/test_issue23_24_startup_path.py tests/test_issue24_resume_dialogs.py tests/test_c1_tui_readiness.py tests/test_b19_headless_reconcile_fixes.py
→ 120 passed in 535.40s (0:08:55) failed 0 · skipped 0
같은 -k 부분집합(새 회귀 4 + 이웃 7) 단독 → 11 passed in 148.21s
```
- 다이얼로그 핸들러를 건드리는 4파일 전부 통과. `c1`·`b19` 의 "프레임당 1회"·`Yes, try it` 가드도 유지된다.
- 실제 `.mam/jobs` 전후 **차이 0**. 검증 경로 tracked 파일 변화 없음(HEAD `05b0ea4`), 본 체크아웃 `main 1e78dae`.
- 시간: 이 호스트에서 다른 스위트 2개가 동시에 돌고 있어 resume 시험 6건이 각 37~41 s 였다(1회차 F6 그대로; R3b 3사례가 더해져 +2 분).
## 4. 병합 차단 여부 (항목 3)
1회차 PASS 를 뒤집을 요소는 없다. 델타는 세 줄의 소스 변경(`lib.sh:2958` `continue`, `create_session.sh:72`·`resume_session.sh:33` 의 환경 기본값)과 문서 한 줄, 회귀 4건이며, 두 탐침의 재현 사례만 정확히 실패→통과로 바뀌었다. 버전 락스텝 bump 부재(`4.7.1`, 오케스트레이터가 4.7.2 로 처리 예정) 외에 **병합을 막을 결함은 없다**.
| # | 비차단 관찰 | 근거 |
|---|---|---|
| G1 | `MAM_AUTO_TRUST` 는 `0` 만 끈다(`false`/`no` 는 on). 세 층(스크립트·핸들러·picker)이 일치하므로 결함은 아니지만 문서에 한 마디 있으면 좋다 | `create_session.sh:72`, `lib.sh:2986`, `_mam_dialog_pick` 의 `!= "0"` |
| G2 | "플래그가 환경보다 우선" 은 create SKILL.md(`:119-120`)에만 있고 resume SKILL.md(`:107`)에는 환경변수 형식만 있다 | 사소 |
1회차 F1(codex 수락 키와 브리프 서술의 차이)은 이 델타의 범위 밖이며 그대로 열려 있다.
## 5. 교차검증 가이드
```bash
RP=/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20260923T220355Z-76dd1d/1
PY=/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.venv/bin/python
git -C "$RP" rev-parse HEAD 'HEAD^{tree}' # 05b0ea4… / 8f5e8b0…
# picker 단독: 신뢰 창 쿨다운 중에도 안내에 답하는지
eval "$(sed -n '/^_mam_dialog_pick() {/,/^}/p' "$RP/.agents/skills/lib.sh")"
D='[["Do you trust the contents of this directory|1\\. Yes, continue",["Enter"],"trust"],["1\\. Update now|Skip until next version",["2"],"notice"]]'
S=$'Do you trust the contents of this directory?\n > 1. Yes, continue\nUpdate available\n 1. Update now\n 2. Skip\n 3. Skip until next version'
_mam_dialog_pick "$S" "$D" 1 "0:1000" 1001 5 # send|1|2 (정정 전: 출력 없음)
_mam_dialog_pick "$S" "$D" 1 "0:1000 1:1000" 1002 5 || echo nothing # S5 유지
# 되돌린 사본에서 회귀가 실패하는지: lib.sh/create/resume 를 f4ba205 판으로 교체 → -k "s6c or s5f or r3b or c3 ..." → 4 failed / 7 passed
```
[VERDICT: PASS]
@@ -0,0 +1,65 @@
# 리뷰 리포트 — Job e93cf9d4 (PR #51, refactor/issue-17-item4)
- 리뷰어: planner-reviewer-claude-01 (claude)
- 리뷰 identity: loop-20261007T142403Z-10d77f / base 7209750 / head 518beda / tree 137b4e4 — 검증 경로에서 `git rev-parse HEAD 'HEAD^{tree}'` 일치, 미커밋 변경 없음
## 결론 요약
동작 불변 계약이 지켜졌다. 옛 bash 판정(tests/data/issue17_item4_old_decisions.sh = 7209750 의 함수)과 새 wrapper.py 를 경로별로 대조해 차이를 찾지 못했고(옛 코드의 따옴표 경로 버그 하나는 새 코드에서 고쳐짐 — 개선), 지정 9개 파일이 이 macOS·Python 3.9·bash 3.2 에서 전부 통과한다. 차단 결함 없음. 비차단 관찰 5건(§8).
## 1. 동작 불변 — 경로별 대조
| 옛 bash (old_decisions.sh) | 새 Python (wrapper.py) | 판단 |
|---|---|---|
| _map_sub_rc :72-90 | sub_status :58-66 | 같음. rc 3 은 디스크 종결 상태가 이김, 그 외 코드는 timeout |
| 구독자 조기 종료 판정 `[ $sub_exit -eq 0 ] \|\| [ … -eq 3 ]` | subscriber_up :69-70 | 같음 |
| _rc_for :92-101 + 호출부의 `MAM_DELEGATE_FAIL_CLOSED=0 → exit 0` | exit_code :77-78, fail_closed :73-74 | 같음. 빈 값은 1 로 보는 `${…:-1}` 와 `or "1"` 일치 |
| cmd_wait rc 4→2 :261 | wait_rc :81-82 | 같음 |
| start_stall_watch 의 감시 여부 :30-36 | stall_watch :89-99 | 같음. 메시지도 동일 |
| finalize_unfinished 앞부분: FAIL_CLOSED=0·completed 면 반환, stall.out 꼬리 3줄, 디스크 종결이면 FINAL=디스크(completed 면 반환) :105-123 | finalize_begin :161-187 (`keep` / `done completed` / `go\|probe <final> <evidence_until> <deadline>`) | 같음 |
| broker_unavailable: 마감까지 ≤3 s 간격 재관측, 매 회 시계로 마감 계산, 마감 도달 시 wall :128-146 | finalize_end :201-216 | 같음(`_sleep` 이 0.5 s 단위로 쪼개 소유자를 확인할 뿐, 대기 총량은 같다) |
| timeout: evidence_until 는 진단 **전**에 계산, 마감까지 디스크 재관측, 디스크 heartbeat 가 창을 다시 열고, 창이 지나면 종료, 마감 도달 시 wall :149-194 | finalize_begin 이 evidence_until 계산(:181) → bash 가 진단 프로브(래퍼 :137-142) → finalize_end :217-238 | 같음. 진단 순서도 같다 |
| 라벨: sub_rc 4 → wall, wall 이고 phase≠inject 면 wall_clock_timeout, preflight/idle_wait 는 cancelled + session_busy 또는 PREFLIGHT_REASON :199-209 | :242-251 | 같음 |
| close rc 4(토큰 불일치) → 이벤트 억제, FINAL 그대로 :216-224 | :256-262 | 같음 |
| close 뒤 디스크 completed 면 completed :226-232 | :263-264 | 같음 |
| direct 만 이벤트: cancelled 또는 (sub_rc≠1 또는 inject) 이면 error(inject 는 agent_blocked) :234-245 | :266-272 | 같음. 토큰 인자도 동일 |
| registry.py close 출력 `2>&1` 을 stdout 으로, publish_event stderr 는 버림 | _run merge=True/False :190-193, 답은 fd 3 | 같음 — 래퍼가 `3>&1 1>&4` 로 stdout 을 원래 stdout 으로 보낸다(래퍼 :145-149). subprocess 기본 close_fds 로 fd 3 은 자식에 새지 않는다 |
- 비교 테스트(test_c_*)가 status×sub_rc×phase 전 조합, 토큰 불일치(CLOSE_RC=4), FAIL_CLOSED=0, remote·human·loop·discuss, 늦은 completed, heartbeat 유예가 마감까지 가는 경우, 래퍼 사망(KILL·미회수 좀비)을 옛·새 양쪽으로 돌린다.
- 직접 돌려 본 미포함 경로: registry 경로에 작은따옴표(`reg it's`)가 있을 때 옛 `_disk_status` 는 경로를 Python 소스에 끼워 넣어 `""` 를 내고(rc 3 + 디스크 completed 를 broker_unavailable 로 오판), 새 sub_status 는 `completed`. 옛 버그가 고쳐진 차이이며 계약 위반이 아니다.
## 2. bash ↔ Python 경계
- `_decide`(래퍼 :97-99)는 `"$@"` 로 인자를 그대로 넘긴다. 빈 값(`--dispatch-token ""`, `--phase ""`, `--preflight-reason ""`)은 argparse 기본값과 같다. `-` 로 시작하는 값이 들어갈 수 있는 인자는 PREFLIGHT_REASON 뿐인데 값은 `target_not_found` 하나(래퍼 :850). `${START_TIME:+--start-time "$START_TIME"}` 는 빈 START_TIME 이면 인자를 빼고 Python 이 now 를 쓴다 — 옛 `${START_TIME:-$(date +%s)}` 와 같다.
- 한 줄 출력: finalize-begin 은 stdout 에 답 한 줄만, 진단은 stderr. finalize-end 는 fd 3 으로 답(fd 3 이 닫혀 있으면 stdout).
- 래퍼에 판정이 남지 않았다: test_s_the_wrapper_carries_no_moved_decision(주석 제외 소스에 _map_sub_rc·_rc_for·FINALIZE_CLOCK·`${MAM_DELEGATE_FAIL_CLOSED`·SUB_RC 비교·라벨 문자열 없음, lib_int.sh 에 BASH_REMATCH 없음).
- knob 환경변수: 옛 bash 는 셸 변수로, 새 Python 은 환경으로 읽는다. 래퍼가 이 두 knob 을 대입하지 않고 .mam.env 는 `export` 줄로 들어오므로(래퍼 :37-45, envfile.py :116) 차이가 없다.
- Python 실패 시: 관찰 N2.
## 3. finalize-end 의 --watch-pid
- `_check_owner` 가 두 대기의 매 반복(0.5 s 단위)과 close 직전(:256), 이벤트 직전(:267)에 래퍼 생존을 본다. 사라졌으면 OwnerGone → 닫기·발행 없이 종료(:338-339).
- `_alive`(:122-137): kill(0) 실패면 죽음, 성공이면 `ps -o stat= -p` 가 `Z…` 이거나 빈 출력이면 죽음. macOS 와 Linux 모두 좀비는 `Z`(macOS `Z`, Linux `Z`/`Z+`)로 시작하고, 사라진 pid 는 빈 출력이다. ps 를 못 돌리면 kill(0) 결과를 믿는다(살아 있음) — 닫는 쪽이 아니라 기다리는 쪽이지만, 래퍼가 사라진 뒤라면 대기는 마감에서 끝나고 닫기가 일어날 수 있다(ps 가 없을 때만, 비현실적).
- 래퍼에 TERM trap 이 없으므로(trap 은 부트스트랩 EXIT 용 :916-942 뿐) TERM 이 `$(…)` 대기 중 지연되는 문제는 없다. 래퍼가 TERM/KILL 로 사라지면 고아가 된 Python 이 0.5 s 안에 알아채고 끝난다 — 옛 bash 루프가 래퍼와 함께 끝나던 것과 같은 결과. 테스트 test_c_finalize_ends_with_its_wrapper / _with_an_unreaped_wrapper / test_c_an_unreaped_wrapper_leaves_the_real_record_alone 통과.
- 정상 경로 지연: 확인마다 ps 한 번(수 ms). 대기의 총길이는 옛과 같다.
## 4. 정수 규칙 (lib_py/ints.py)
- positive_int 는 runner.py 의 옛 _positive_int 를 그대로 옮긴 것이고(diff 상 동일 본문), 옛 bash mam_int 와도 같은 규칙이다: ASCII 공백 6종만 앞뒤 허용, ASCII 숫자만(`٥` 거부, 로케일 무관), 선행 0 제거 뒤 9자리 이하, 최솟값. knob 의 경고 문구도 동일. test_c_mam_int_and_knob 이 C·en_US.UTF-8 두 로케일에서 옛·새를 비교하고 통과.
- lib_int.sh 를 lib.sh 없이 source 하는 diff_collect.sh·loop_lock.sh: `_MAM_INTS_PY` 를 BASH_SOURCE 기준으로 잡으므로 동작한다(test_issue21_loop_b l15 는 그 배치를 재현하도록 lib_py/ints.py 도 복사). 비용·실패는 N3.
## 5. 수정된 정적 테스트 4개
약해지지 않았고 오히려 강해졌다: 각 테스트가 래퍼 소스의 `_decide …` 호출 문자열을 계속 고정하면서, 옮겨진 표 자체(EXIT_CODES, rc 0~5 의 sub_status, rc 3 의 디스크 우선, SUB_RC_* 와 래퍼 상수의 일치)를 실제 함수로 검사한다. test_issue26_ints 의 NOT_ARITHMETIC 에서 두 knob 을 뺀 것은 그 knob 들이 셸에서 사라졌기 때문이며 정적 가드가 재유입을 막는다.
## 6. 성능
- `_decide` 한 번 ≈ 50 ms(5회 0.25 s), 그중 약 29 ms 는 `lib_py.delegation` 패키지 import(N1). 잡 하나당 호출은 정상 경로에서 3~5회(sub-status, exit-code, stall-watch, 미완료 시 finalize-begin/-end).
- `mam_int`/`mam_int_knob` 는 호출당 ≈ 47 ms(10회 0.47 s; 옛 bash 는 ≈ 0.1 ms). 래퍼 기동 시 knob 4~5개 + 플래그, send_keys 경로의 knob 몇 개. 모두 폴링 루프 밖(마감 계산은 1회)이라 잡당 1 s 미만이며 에이전트 턴 시간에 비해 무시할 수준.
## 7. 이식성
이 macOS·/usr/bin/python3 3.9.6·bash 3.2 에서 지정 9개 파일 통과. wrapper.py·ints.py 는 3.9 문법(`from __future__ import annotations`, typing.List), /proc 없음(ps 사용), str.isascii(3.7+).
## 8. 비차단 관찰
- **N1** wrapper.py 머리말의 "Stdlib only" 와 달리 `python -m lib_py.delegation.wrapper` 는 패키지 `lib_py/delegation/__init__.py` 를 먼저 import 해 service → mqtt_common → **paho** 를 싣는다(paho 없는 python3 에서 `ModuleNotFoundError: paho` 확인). 래퍼의 `$PY` 는 원래 paho 가 필요하므로 동작에는 문제없지만, 문서 정정이나 패키지 밖 모듈(ints.py 처럼 경로 실행)로 두면 호출당 ~29 ms 도 줄어든다.
- **N2** `_decide` 가 실패하면(모듈 import 오류 등) `exit "$(_decide exit-code …)"` 는 `exit ""` → bash 3.2 에서 rc 255("numeric argument required"), finalize 도 실패해 잡이 열린 채 남는다. 옛 코드도 같은 `$PY` 로 registry.py 를 돌렸으므로 실질 회귀는 아니지만, `exit "${rc:-1}"` 같은 기본값이 있으면 fail-closed 코드(1)가 보장된다.
- **N3** lib_int.sh 는 `$PY` 가 아니라 PATH 의 `python3 -I` 를 쓴다. python3 가 없으면 mam_int_knob 이 아무것도 출력하지 않아(옛 bash 는 항상 기본값 출력) `$(( $(date +%s) + $(mam_int_knob …) ))`(lib.sh:2783 등)가 산술 오류가 된다. MAM 은 python3 를 이미 필수로 요구하므로 현실적 위험은 낮다.
- **N4** 옛 `_disk_status` 의 작은따옴표 경로 버그(§1)가 새 코드에서 고쳐졌다 — 동작 차이지만 개선.
- **N5** 브리프·PR 설명의 "0.2 s 마다 확인"과 달리 코드는 0.5 s(`_sleep`, :145-153). 설명 정정만 필요.
## 9. 테스트
검증 경로에서 지정 9개 파일(pr-51 .venv, Python 3.9.6): **604 passed / 1 skipped / 0 failed** (1009 s, rc 0). skip = test_issue30_stall_watch.py:340 "real process trees via /proc"(Linux 전용, 기존). MQTT_* unset, .mam.env 미export. 실제 .mam/jobs 항목 수 변화 없음(1052 → 1052).
[VERDICT: PASS]
@@ -0,0 +1,165 @@
# 🔍 Review Report — PR #41 병합 전 독립 리뷰 + 설계 합의 (job `ed0e1492`)
- **Reviewer**: `planner-reviewer-claude-01` (role: planner,reviewer) · 2026-09-26
- **대상**: PR #41 `fix/issue-30-33` **@ `1673818`**, base `main 8ea65bb` (v4.7.4). 6커밋, 30파일 `+2047/−95`, `git diff --check` 클린.
- **검증 경로**: `/var/folders/.../T/mam-review/loop-20260926T025230Z-a30a6d/1`. **identity 확인**: `HEAD = 1673818b…e91f`, `HEAD^{tree} = 00d5e03e…9e92` — 브리프와 일치.
- **안전 규칙 준수**: 검증 경로·소스 워크트리(`multi-agent-mux-wt/pr-41`)·본 체크아웃(`main 8ea65bb`) 모두 수정·커밋·checkout 없음. 실험은 in-process 러너(`env -u MAM_VERSION`)·래퍼 `--dry-run`(`--agent human`, 도달 불가 브로커)·되돌린 사본에서만. **stall_watch 를 실제 세션에 붙이지 않았고**, 실제 herdr 서버에 위임 잡을 만들지 않았다. push·merge·PR 상태 변경 없음.
## 결론 요약
**병합을 막을 결함은 없다.** #33 의 예산 전달·우선순위와 두 시계 라벨, #30 감시자의 "알림 전용" 성질(발행하는 이벤트가 `progress` 뿐, 잡을 닫는 코드 경로 없음)을 코드·실험·테스트로 확인했다. 경계 기준으로 보면 bash 에 새 **판정** 이 두 곳 남아 있고(§4 표: 디스크 heartbeat 유예의 시계 판정, 감시 대상 제외 판정) 이는 항목 4 이전 대상이므로 비차단으로 분류한다. 비차단 관찰 7건(M1–M7). 설계 합의 4항목은 §8 에 별도로 답했다.
## 1. #33 예산 (항목 1)
| 확인 | 결과 |
|---|---|
| 래퍼 우선순위 | `--timeout` > `MAM_JOB_TIMEOUT_S` > 3600. `--dry-run` 실측으로 등록된 `timeout_sec`: 없음→3600, env 900→900, env 900 + `--timeout 120`→**120**, env `abc`→3600 + `WARNING: … ignored (not a positive integer)`, env `-7`→3600 + 경고 |
| 러너 우선순위 | `job_budget`(`runner.py:595-600`): `--job-timeout` > env(양의 정수) > 3600. in-process 실측: 기본 3600/대기 3900, env 900→900/1200, flag 120 > env 900→120/420, env `abc`·`0`→3600(**무언**, M4), `--job-timeout 0/-5/x` → exit 1 |
| 모든 위임에 전달되는가 | `contract_args()`(`:464-470`)가 **항상** `--timeout <budget>` 을 앞에 붙이고, 구현·리뷰·수정·재심·중재·순응 잡은 전부 `_dispatch → delegate_job → _run_cmd(… + contract_args())` 를 지나므로 예외가 없다(실측 `['--timeout','3600']`). 래퍼 쪽에서는 `--timeout` 플래그가 env 를 이기므로 루프의 예산이 등록된다(B2) |
| 대기 상한 | `job_wait_s = budget + MAM_WAIT_GRACE_S(300)`(`:603-609`); `wait_for_job` 기본 `max_wait` 와 `MAM_CHILD_WAIT_S` 기본값이 이를 따르고 명시 `MAM_CHILD_WAIT_S` 는 존중(`:729-732`, 로그에 출처 표기). `MAM_WAIT_GRACE_S=10` → 610 실측 |
| 원격 잡 | `remote.py` 는 `job.timeout_sec`(등록된 예산)로 명령을 죽이므로 예산이 그대로 적용된다; 라벨은 §2 |
## 2. #33 라벨 (항목 2)
- **상수**: `job_subscriber.py:53-61` `RC_COMPLETED/ERROR/NO_EVENT/BROKER_UNAVAILABLE/WALL_CLOCK = 0/1/2/3/4`, 래퍼 `:144` `SUB_RC_*` 동일값. `test_issue5_phase1_delegation.py::test_wrapper_exit_code_mapping_literals_are_frozen` 이 `SUB_RC_*` 사전을 `RC_*` 와 **사전 단위로 비교**하고, 래퍼가 숫자로 비교하는 자리가 없음·구독자가 숫자 `return` 을 하지 않음을 정규식으로 고정한다.
- **래퍼 종결 경로별 라벨**(`finalize_unfinished`, `:860-1000`):
| 경로 | 라벨 |
|---|---|
| 구독자 rc 4 | `clock=wall` → `close --request wall_clock_timeout` + `error --detail wall_clock_timeout` |
| 구독자 rc 2 | `clock=idle` → `deadline_timeout` |
| rc 2 뒤 디스크 heartbeat 유예가 `$deadline` 에 닿음 | `FINALIZE_CLOCK=wall` → `wall_clock_timeout`(R3) |
| rc 3(broker_unavailable) 재관측이 `$deadline` 에 닿음 | 같은 규칙으로 `wall` |
| inject 단계 | `agent_blocked` 유지(`phase != inject` 조건) |
| preflight/idle_wait | `cancelled`(`session_busy`/`target_not_found`) 유지 |
- 등록부 `close --request` 에 `wall_clock_timeout` 추가(`registry.py:424`), `engine.REQUEST_TO_STATUS["wall_clock_timeout"]="error"`, #34 표의 `ORCHESTRATOR_CLOSED` 에 포함 + `.rstrip(":")` 로 원격 문구 `wall_clock_timeout: agent command exceeded…` 도 첫 단어 판정을 통과(B5).
- `delegate-job wait`: rc 4→2 매핑(`:1214`)으로 하위 호환(B7, G-8 은 idle 30 s/wall 0.5 s 로 시계를 분리해 결정적).
- 벽시계로 닫힌 뒤 에이전트의 늦은 `completed`: `finalize_unfinished` 가 디스크 상태가 이미 종단이면 건너뛰고(`:875-877`), `engine.transition` 이 종단 뒤 종단을 거부하는 기존 규칙(#23 리뷰에서 확인한 "conflicting … after terminal")이 그대로다 — 이 PR 이 바꾸지 않았다.
## 3. #30 감시자 `stall_watch.py` (항목 3)
- **잡을 닫는 경로 없음**: 파일 전체에서 `--event` 는 `_notify` 의 `progress` 하나뿐(`:237`), `close`/`error`/`cancelled`/`update_status`/`transition` 호출 0건(grep). `judge()` 는 순수 함수로 `NOTIFY_BLOCKED`/`NOTIFY_IDLE_AND_NUDGE`/`None` 만 낸다. S12 가 소스 수준에서 같은 것을 고정한다. 종료 코드는 0(잡 종단)·2(herdr 불가)뿐 → **갱신 1 준수**.
- **herdr 조회 방식**: `watcher.wait_once`(`agent wait --until … --timeout`)의 서버 소유 배리어를 상태별 `until` 로 돌리고(`:274-284`), idle 뒤에는 30 s 단위로 재판정한다. 폴링·구독이 아니라 배리어 + 판정 시점의 `agent get`/`pane process-info`/`agent read`.
- **blocked**: `pane read --source detection --lines 8` 의 첫 줄(비ASCII 제거, 160자)을 붙여 `stalled:blocked <문구>` 를 에피소드당 1회 발행(`:318-324`), **키 입력 없음** — 갱신 1 의 "자동 수락 없음" 과 일치. `blocked → 다른 상태 → blocked` 면 다시 1회.
- **idle**: `MAM_STALL_IDLE_S`(600) 연속 idle + `agent_pid`(foreground 프로세스, shell 아님) 있음 + 백그라운드 자손 없음(herdr pid → OS 자손 트리에서 어댑터 `resident_children` 과 heartbeat 타이머 제외, `:118-136`) + 입력창 비어 있음(`extract_input_region == ""`) 일 때만 `stalled:idle` + 재주입 **잡당 1회**(`st.nudged`). `state_change_seq` 가 창 안에서 바뀌었으면 창을 다시 시작(R4). 입력 사실이 없는 에이전트는 보류 + 1회 알림. 재주입 문구(`NUDGE`)는 영문·ASCII·1문장이며 "끝났으면 completed/error 발행, 아니면 이어서" 라 오작동 여지가 작다.
- **원격·human 제외**: 래퍼 `start_stall_watch`(`:684-702`)가 `remote:`/`human` 이면 기동하지 않고, `MAM_STALL_WATCH=0` 으로 끌 수 있다(S9/S10).
- **herdr 실패**: `TRANSPORT_ERROR`/`AGENT_NOT_FOUND` 는 "이벤트 없음"으로 읽지 않고 1회 알림 + 백오프, `MAM_STALL_HERDR_FAIL_S`(120) 뒤 rc 2(S8).
- **수명**: 래퍼가 `wait "$sub_pid"` 직후 `stop_stall_watch`(TERM → `os._exit(0)`)로 거둔다(`:385`, `:560`). **M3**: `start_stall_watch` 와 `stop_stall_watch` 사이의 비정상 종료(`set -e` 오류, 검증 훅 실패의 `exit`, 래퍼 SIGKILL)를 덮는 EXIT trap 이 없다. 감시자는 잡 레코드가 종단이 되면 다음 반복(≤ 60 s 배리어)에서 스스로 끝나므로 잡이 닫히는 한 고아 수명은 유계이지만, 잡이 `running` 으로 남는 경우(래퍼 -9)에는 herdr 가 살아 있는 동안 계속 돈다. `.stall.out` 은 `.mam/jobs/<jid>.stall.out` 에 남는다(작고, #20 가드의 `*.json` 감시 밖).
- **실제 시험 A/B 재현성**: 실제 세션에 붙이는 것은 금지라 재현하지 않았다. mock herdr 위의 S2/S3/S7/R1/R2/R4 가 같은 신호 흐름을 실제 `python -m lib_py.stall_watch` 프로세스로 돌린다.
## 4. 경계 기준 — bash 에 들어온 판정 (항목 4)
| 위치 | 내용 | 성격 | 분류 |
|---|---|---|---|
| 래퍼 `:146-151` | `MAM_JOB_TIMEOUT_S` 양의 정수 검증·경고 | 입력 검증(기본값 결정) | 항목 4 이전 대상, 비차단 |
| `finalize_unfinished` `:902`, `:950` | 디스크 heartbeat 유예/broker 재관측 루프가 `$deadline` 에 닿았는지로 `FINALIZE_CLOCK=wall` | **시계 판정** — Python 반환값이 아니라 bash 가 결정 | 항목 4 이전 대상, 비차단(**M1**) |
| `:956-959` | `sub_rc == SUB_RC_WALL_CLOCK → clock=wall`, `phase != inject` 면 `wall_clock_timeout` | rc→라벨 **운반** + inject 예외(판정) | 운반은 기준 부합; inject 예외는 기존 구조 |
| `:684-702` | remote/human 제외, `MAM_STALL_WATCH` | 감시 여부 **판정** | 항목 4 이전 대상, 비차단(**M2**) |
| `cmd_wait` `:1214` | rc 4→2 | 호환 매핑(운반) | 부합 |
| `stop_stall_watch` | rc≠0 이면 로그 꼬리 출력 | 실행부 | 부합 |
"bash 는 라벨을 나르기만 한다"는 주장은 **구독자 rc 경로에 한해** 맞다. 유예 루프의 시계 판정(M1)과 감시 대상 판정(M2)은 bash 의 결정이다. 둘 다 항목 4(래퍼 종결 처리의 Python 이전)에서 옮길 대상이고 PR 본문이 항목 4 를 미룬다고 밝혔으므로 비차단.
## 5. 기존 테스트 조정·새 테스트 (항목 5)
- G-8: idle 0.5/wall 0.5 의 경합을 idle 30/wall 0.5 로 풀어 **어느 시계인지가 결정적**이 됐고 rc 는 `RC_WALL_CLOCK` 으로 단언 — 의도 유지.
- issue5 phase1: 스냅샷이 "숫자 return 금지·이름 비교만" 으로 **강화**됐고 `help-registry.txt` 는 `close` 선택지 갱신. issue18: `contract_args` 에 `--timeout 3600` 이 앞에 오는 것만 갱신. `conftest.py`: mock herdr 에 `force_error_code`·detection 소스·실제 `process-info` 형태·`state_change_seq`·`wait_blocks_s` 추가 — 기존 동작은 기본값으로 유지.
- 새 테스트: `_env()` 가 `HOME`·`WORKSPACE_ROOT`·로그 루트를 `tmp_path` 로, 브로커를 도달 불가로 고정; 감시자는 실제 `python -m lib_py.stall_watch` 프로세스, 래퍼도 실제 스크립트(`--agent human`), herdr 는 mock. 실제 `.mam`·홈·herdr 접촉 없음(§6 실측).
- pane 픽스처 5종: `extract_input_region` 을 5개 idle 캡처에 돌려 **전부 `''`**(S14 의 typing 변형이 비어 있지 않음을 고정). 캡처 자체가 실제 화면과 같은지는 이 리뷰에서 재현할 수 없고(실제 세션 접근 금지), PR 의 "herdr 0.9.1 캡처" 기록에 의존한다.
## 7. 문서 (항목 6)
- delegate-job SKILL.md: `MAM_JOB_TIMEOUT_S`·`MAM_STALL_IDLE_S`/`MAM_STALL_HERDR_FAIL_S`/`MAM_STALL_WATCH=0` 행(`:49-50`), 종료 코드 표에 `2 | Wall clock | error (wall_clock_timeout)` 행(`:100`) 추가 — 코드와 일치. **M5**: 그 행의 "was `deadline_timeout` before v4.7.5" 는 브리프의 병합 버전(**4.8.0**, MINOR)과 어긋난다 — 릴리스 커밋에서 문구 수정.
- loop SKILL.md: `--job-timeout` 행(`:209`)과 "하위 위임 잡" 절의 `MAM_CHILD_WAIT_S` 기본값이 "잡 예산 + 300 s" 로 갱신(`:222`) — 코드와 일치. **M6**: `MAM_WAIT_GRACE_S`(러너의 +300 을 바꾸는 손잡이, `runner.py:608`)는 어느 문서에도 없다.
- 래퍼 usage 의 `--timeout` 기본값 서술 갱신 ✓.
## 8. 설계 합의
**(A) #30 알림 전용 원칙 — 동의.** 근거: heartbeat 는 프로세스 생존만 증명하고(#18 의 절충), herdr 의 `agent_status` 도 "working 으로 보고되는 멈춤"(codex 재접속 사례)을 못 잡는다. 그런 불완전한 신호로 잡을 닫으면 #18 이 세운 "프로토콜 이벤트만이 종결 근거" 계약을 깨고, 정상적인 장기 작업(전체 스위트 2회)까지 닫게 된다. 알림은 사람·루프가 판단할 근거를 주면서 계약을 유지한다. **이 PR 은 그것을 코드로 강제한다**: `stall_watch.py` 의 유일한 발행기 `_notify` 가 `progress` 만 내고, 종결 API(`close`/`error`/`cancelled`/`transition`) 호출이 파일에 없으며, `judge()` 의 반환값이 알림 두 종류뿐이다. S12 가 소스 수준에서 이를 고정하므로 후속 커밋이 조용히 종결 경로를 넣기 어렵다. 한 가지 보강 권고: `judge()` 의 반환 타입을 열거형으로 좁혀 "닫기" 라는 값이 애초에 표현 불가능하게 두면 테스트 없이도 구조가 원칙을 말한다.
**(B) 예산 연장을 #39 로 분리 — 조건부 동의.** 러너가 래퍼를 동기 호출하는 구조에서 "래퍼가 멈춰서 묻고 루프가 정책으로 답한다" 는 원안은 래퍼 안에서 정책을 결정하거나 파일 기반 결정 채널을 추가해야 하고, `state_change_seq` 정체는 정상 장기 작업도 거부한다는 PR 의 관찰에 동의한다 — 이 PR 의 범위에서 분리한 것은 맞다. #39 방식(에이전트가 프로토콜 이벤트로 연장을 요청)의 위험: (1) 에이전트가 반복 요청해 사실상 무한 예산이 되는 것, (2) 원격 잡은 러너 쪽 `timeout_sec` 이 별도 시계라 요청이 닿아도 명령이 먼저 죽을 수 있는 것, (3) 상한이 정책이 아니라 에이전트의 자기 보고에 걸리는 것. **보완책 한 가지**: 연장 요청을 `progress --detail extend_request:<sec>` 로 받되, **결정은 받는 쪽(구독자/루프)이 `MAM_JOB_EXTEND_MAX`(기본 1)·연장 총량 상한·직전 연장 뒤 `state_change_seq` 변화 여부**로 내리고, 승인 시 등록부의 `timeout_sec` 을 CAS 로 갱신해 원격 러너도 같은 값을 읽게 한다 — 즉 요청은 에이전트가, 결정은 Python 이(경계 기준). 상한은 남긴다.
**(C) #40 트리거 — 동의(래퍼 구독자 쪽).** 제어 평면(YAML·SQLite 세션 등록부)은 오케스트레이터 소유(#15/#20/#21)이므로 에이전트 프로세스가 실행하는 `publish_event.py` 에서 세션 ID 를 캡처·고정하는 것은 소유권을 거스른다. 두 후보 중 **래퍼 구독자**가 맞다: 루프 러너의 `wait_for_job` 은 루프 밖 직접 위임(`delegate-job submit --type direct`, 워크트리 creator 의 실제 시나리오)을 지나지 않지만, 래퍼 구독자는 모든 위임(루프 안팎, `MAM_NO_SUBSCRIBER_DAEMON=1` 인 워크트리 creator 포함 — 그 경우 래퍼가 잡별 구독자를 직접 띄운다)의 `completed` 를 받는 단일 지점이기 때문이다. 루프 러너는 그 결과를 읽기만 하면 된다. **순서**: 항목 4 가 래퍼 종결 처리를 Python 으로 옮길 때 그 종결 핸들러에 `capture_active_session_id → auto_pin_if_needed` 를 끼워 넣는 것이 가장 싸다 — 항목 4 에 흡수. 항목 4 보다 먼저 해야 한다면 구독자의 `completed` 처리에 Python 훅으로 넣고 bash 는 손대지 않는다.
**(D) 항목 4 를 미룬 것 — 조건부 동의.** 이 PR 이 항목 4 까지 안았다면 라벨 분리·감시자·종결 처리 이전이 한 리뷰 왕복에 겹쳐 검증 면적이 두 배가 됐을 것이므로, bash 변경을 라벨 운반으로 최소화한 선택은 타당하다. 조건은 §4 표의 두 판정(M1·M2)이 **항목 4 의 명시적 이전 목록**에 올라가는 것이다. 항목 4 에서 Python 으로 옮겨야 할 것: (1) `finalize_unfinished` 전체 — 특히 디스크 heartbeat 유예 루프와 `$deadline` 비교로 시계를 정하는 `FINALIZE_CLOCK`(`:902`, `:950`), `sub_rc → close_req` 매핑과 inject 예외(`:956-959`); 구독자가 "어느 시계인지" 를 이미 rc 로 알고 있으므로 유예 루프 자체를 구독자(또는 그 옆의 Python 종결기)로 옮기면 bash 의 판정이 사라진다. (2) `start_stall_watch` 의 감시 여부 판정(remote/human/`MAM_STALL_WATCH`) — Python 종결기/구독자가 잡 레코드의 `transport`·`agent` 로 결정하고 bash 는 프로세스만 띄운다. (3) `MAM_JOB_TIMEOUT_S` 검증 — 등록부 `register` 가 예산 기본값을 결정하게 하면 래퍼·러너의 이중 검증(M4 의 불일치)도 사라진다. bash 에 남길 것: 감시자·구독자 프로세스 기동/회수(`stop_stall_watch`), 키 주입, 로그 꼬리 출력.
## 6. 테스트 실행과 **차단 결함 N1** (항목 5·7)
### 6-1. 검증 경로에서의 실행
```
tests/test_issue30_stall_watch.py tests/test_issue33_budget.py tests/test_tier1_unit.py tests/test_issue5_phase1_delegation.py
tests/test_issue18_liveness_contract.py tests/test_issue34_job_tree.py tests/test_issue14_delivery_ack.py tests/test_loop_cli.py
→ 3 failed, 222 passed, 48 skipped in 679.80s (0:11:19)
FAILED test_issue33_budget.py::test_b3_a_heartbeating_job_that_spends_its_budget_is_wall_clock_timeout
FAILED test_issue18_liveness_contract.py::test_send_keys_safe_is_confirmed_by_the_job_started_event
FAILED test_issue14_delivery_ack.py::test_wrapper_honours_registry_ack_over_unconfirmed_inject (TimeoutExpired 90 s)
```
- **skip 48 = `test_issue30_stall_watch.py` 전체.** `:39` 의 모듈 수준 `pytestmark = skipif(not linux, "real process trees via /proc")` 가 순수 함수 시험(judge 표·S12·S14·`scoped_env`·추출기 R1/R1b)과 mock herdr 만 쓰는 S9/S10/S11 까지 건너뛴다. 사본에서 그 마크만 지우고 `/proc` 무관 시험을 돌리자 **29 passed** — 이 호스트에서 돌 수 있는 것들이다(M8). `background_children` 의 macOS `ps` 경로는 스위트가 안 타므로 직접 확인했다: 자식 `sleep` 감지, heartbeat 타이머 제외, `resident` 필터, 종료 후 빈 목록 — 모두 정상.
- **b3(heartbeating → wall_clock)**: 단독 재실행에서도 실패. `subscriber.out` 은 `idle timeout (3s, no events)` — 이 시험은 `broker=True` 로 **공개 브로커**를 지나 1 s heartbeat 가 3 s idle 창 안에 도착해야 벽시계(6 s)가 먼저 울린다. 이 호스트의 브로커 지연(~2 s, 스파이크 9 s — 이전 리뷰들의 T-1 부류)에서는 idle 이 먼저 울려 `deadline_timeout` 이 된다. **PR 결함이 아니라 시험 설계의 브로커 의존**(M9)이지만, 이 시험이 #33 의 동기 사례를 고정하는 유일한 자리라 결정적으로 만들 필요가 있다(로컬 events.log 로 heartbeat 를 넣거나 idle 창을 넉넉히).
- **send_keys_safe**: PR 미접촉 파일의 T-1 flake(단독 재실행 통과).
- **issue14 `test_wrapper_honours_registry_ack_over_unconfirmed_inject`: 단독 재실행은 통과했으나 합동 실행에서 래퍼가 멈춰 있었다 → N1.**
### 6-2. N1 (차단) — 래퍼가 stall 감시자를 기다리며 영구 정지, 잡은 `running` 으로 남는다
실측(모두 읽기 전용 관찰; 마지막 한 번만 **내 샌드박스** 프로세스에 TERM 을 보냈다):
| 관찰 | 값 |
|---|---|
| 합동 실행 뒤 호스트에 남은 `lib_py.stall_watch` 프로세스 | 2개 — 하나는 **내** 실행의 잡 `d494fd13`(basetemp `p41-basetemp/test_wrapper_honours_registry_0`), 하나는 **다른 리뷰어** 실행의 같은 시험(`pytest-0/test_wrapper_honours_registry_0`, 잡 `b0e6a171`, 13분 경과) |
| 두 래퍼(bash) 의 상태 | 살아 있고 `sample` 상 메인 스레드가 `__wait4` 에서 대기, **자식은 감시자 하나뿐**(구독자는 이미 종료) |
| 잡 레코드 | 둘 다 `status: running`, `timeout_sec 6` — 벽시계 6 s 가 몇 분이 지나도 닫지 못함 |
| 감시자 로그 | 내 것은 비어 있음(정상 루프), 다른 리뷰어 것은 `input box … not empty; nudge withheld` 가 2 s 간격으로 116줄 — 감시자는 정상 동작 중 |
| 내 감시자에 `kill -TERM` | 즉시 종료 → **래퍼도 즉시 종료** |
| 감시자 단독 TERM 실험(가짜 herdr, 시작 후 0.05/0.15/0.3/0.6/1/2 s) | 전부 종료(rc 143/0) — 감시자 자체는 TERM 을 받는다 |
즉 래퍼는 **감시자 프로세스가 끝나기를 기다리고**, 감시자는 **잡이 종단이 되기를 기다리며**(`stall_watch.py:270-272` 가 유일한 정상 종료 조건), 잡을 종단으로 만드는 것은 그 래퍼다 — 순환 대기. 래퍼가 어느 `wait` 에 있는지는 `sample` 이 bash 심볼을 주지 않아 못 박지 못했다(`stop_stall_watch` 의 `wait "$STALL_PID"` 가 유력하나, 그 앞의 `kill` 이 왜 닿지 않았는지는 재현하지 못했다). 그러나 원인이 무엇이든 **방어가 없다는 것**은 코드로 확정된다:
- `multi-agent-mux-delegate-job:706-719` `stop_stall_watch`: `kill` 뒤 `wait "$STALL_PID"` 가 **무한**이다. 상한도, `kill -9` 승격도 없다. TERM 이 한 번이라도 유실·무시되면 래퍼는 영원히 선다.
- `stall_watch.py:263-312` `run()`: 부모 래퍼의 생존을 보지 않는다(#18 heartbeat 타이머의 `--watch-pid` 같은 결속이 없다). 잡이 종단이 되지 않는 한(래퍼가 죽거나 멈추면 영원히) herdr 가 살아 있는 동안 계속 돈다.
- 결과는 갱신 1 의 "종결은 벽시계·무응답 경로에만" 과 정반대다: 벽시계 경로 자체가 실행되지 못해 잡이 **닫히지 않는다**. 루프 쪽은 `budget+300` 뒤 `wait_for_job` 이 포기하지만 래퍼·감시자·`running` 레코드는 남고, #34 표에는 OPEN 으로 영구히 보인다.
같은 시험이 이 PR 이전(#37·#38 리뷰의 같은 호스트)에는 한 번도 멈춘 적이 없고, 오늘 두 리뷰어의 합동 실행에서 각각 멈췄다. 단독 재실행이 통과하므로 부하·타이밍 의존이지만, 위임 래퍼의 영구 정지는 강도가 높아 **차단**으로 분류한다.
→ **닫는 방법(작음)**: (1) `stop_stall_watch` 를 유계로 — `kill`, 최대 N s(예: 5) 폴링 대기, 남으면 `kill -9`, 그 뒤 `wait`; (2) 감시자에 `--watch-pid <래퍼 pid>` 를 주고 `run()` 루프 머리에서 부모가 사라지면 rc 0 으로 끝나게(heartbeat 타이머와 같은 계약); (3) S9 에 "감시자가 TERM 을 무시해도 래퍼는 N s 안에 돌아온다" 회귀를 추가. (1)만으로도 정지는 사라지고, (2)가 고아를 없앤다.
### 6-3. 수정 없이는 실패하는가
검증 경로 사본에서 PR 이 바꾼 소스 14파일을 `8ea65bb` 판으로 되돌리고(새 모듈 `stall_watch.py` 는 그대로) 새 파일 2개를 돌린 결과 **14 failed / 6 passed / 48 skipped**: B1(env 검증)·B2(예산 전달·대기 상한)·B3·B4(원격 라벨)·B5(#34 표)·R3 가 실패 — #33 회귀는 정정을 정확히 겨냥한다. #30 파일은 Linux 전용 마크로 사본에서도 skip 이라 이 방법으로는 판정할 수 없다(새 모듈이므로 모듈 부재 = 전부 실패, 자명).
### 6-4. 격리
실제 `.mam/jobs`·`.mam/delegate_job_logs` 전후 **차이 0 / 차이 0**. 검증 경로 tracked 파일 변화 없음(HEAD `1673818`), 본 체크아웃 `main 8ea65bb`. 실제 세션·herdr 서버 접촉 없음. 단, 위 N1 의 **다른 리뷰어의** 멈춘 래퍼·감시자(pid 11266/12518, `pytest-0/...`)는 내 것이 아니라 그대로 두었다 — 오케스트레이터가 정리할 것.
## 9. 비차단 관찰
| # | 관찰 | 근거 |
|---|---|---|
| M1 | 디스크 heartbeat 유예·broker 재관측 루프의 시계 판정(`FINALIZE_CLOCK=wall`)이 bash 의 결정 | 래퍼 `:902`, `:950`; 항목 4 이전 대상 |
| M2 | 감시 여부(remote/human/`MAM_STALL_WATCH`) 판정이 bash | `:684-702`; 항목 4 이전 대상 |
| M3 | 감시자 수명이 래퍼의 정상 경로에만 묶여 있고 EXIT trap 이 없다 — N1 의 구조적 배경 | `:370-385`, `:706-719` |
| M4 | `MAM_JOB_TIMEOUT_S` 가 잘못된 값일 때 래퍼는 경고, 러너는 무언 | `runner.py:595-600` |
| M5 | delegate-job SKILL.md `:100` "before v4.7.5" — 병합 버전은 4.8.0 | 문서 |
| M6 | `MAM_WAIT_GRACE_S` 미문서 | `runner.py:608` |
| M7 | `MAM_TEST_UNRELIABLE_AGENT` 테스트 훅이 제품 코드에 있음 | `stall_watch.py:228` |
| M8 | `test_issue30_stall_watch.py:39` 모듈 마크가 `/proc` 무관 29건까지 skip | §6-1 |
| M9 | B3 heartbeating 시험이 공개 브로커 지연에 의존(이 호스트 2/2 실패) | §6-1 |
버전 락스텝 bump 부재(`lib.sh:32` `4.7.4`, 4.8.0 예정)는 브리프대로 판정에서 제외한다.
## 10. 교차검증 가이드
```bash
RP=/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20260926T025230Z-a30a6d/1
PY=/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.venv/bin/python
git -C "$RP" rev-parse HEAD 'HEAD^{tree}' # 1673818… / 00d5e03…
# N1: 합동 실행 뒤 남은 감시자와 그 부모 래퍼
ps -eo pid=,ppid=,stat=,etime=,args= | grep '[l]ib_py.stall_watch' # 부모 bash 가 __wait4 에서 대기, 잡 레코드 running
# 감시자에 TERM → 래퍼가 즉시 풀림 (자기 샌드박스 프로세스에만)
# 예산/라벨: --dry-run 으로 timeout_sec 확인, in-process 로 job_budget/job_wait_s/contract_args (§1)
# 되돌린 사본: 소스 14파일을 8ea65bb 판으로 → test_issue33_budget.py → 14 failed / 6 passed
```
[VERDICT: NOT PASS]
@@ -0,0 +1,14 @@
{
"loop_id": "loop-20261007T224638Z-cf1efb",
"base_sha": "56d0bfda54e4f4fe1ccc138cc262edfb14cfb4a3",
"head_sha": "65b78b6ec44e44904afe34915563522d4631767f",
"tree_sha": "b805d9f9e6639c7359115ea9336eac95d7ab85bc",
"review_commit": "65b78b6ec44e44904afe34915563522d4631767f",
"reviewer": "planner-reviewer-claude-01",
"job_id": "f0f131cd",
"verdict": "PASS",
"transport": "herdr",
"evidence": "review-tree",
"isolation": "worktree",
"verified_at": "2026-10-08T01:16:15Z"
}
@@ -0,0 +1,40 @@
# 리뷰 리포트 — Job f0f131cd (Issue #17 후속 2회차 델타, 8241bd5..65b78b6)
- 리뷰어: planner-reviewer-claude-01 (claude)
- 리뷰 identity: loop-20261007T224638Z-cf1efb / base 56d0bfd / head 65b78b6 / tree b805d9f — 검증 경로(…/2)에서 `git rev-parse HEAD 'HEAD^{tree}'` 일치, 미커밋 변경 없음
- 범위: 커밋 2개 — 541c3fe(agy 지적: 동결 리터럴을 `_decided`/`_exit_for` 로), 65b78b6(codex R1: 실패한 yes/no 판정은 "no" 가 아니다). wrapper.py +24/−4, 래퍼 +18/−4, 테스트 3개. 버전·VERSIONS·문서 변경 없음(브리프대로).
## 결론 요약
codex R1 이 닫혔고, 그 수정이 1회차에 내가 남긴 N1(stall-watch 판정 실패 시 조용히 감시 생략)도 함께 닫는다. 정상 경로(yes/no 두 분기 모두)의 동작은 불변. 새 테스트 15건이 1회차 head 코드에서 실제로 실패하며, 8건은 codex 가 지적한 바로 그 증상(rc 0)으로 실패한다. 차단 결함 없음.
## 1. 지적 종결
| 지적 | 수정 | 확인 |
|---|---|---|
| codex R1: `_decide subscriber-up`/`stall-watch` 를 rc 로 읽어, 판정 **실패**(rc 1·127, 빈 답)가 "no" 와 구분되지 않음. subscriber-up 에서는 rc 0 으로 끝난 구독자가 "not up" → "died early" 경로 → sub-status 0 = completed → **submit 이 0 으로 종료** | wrapper.py:8-17, :335-340, :344-353 — 두 yes/no 서브커맨드에 `--print`(yes/no 출력 + rc 0). 래퍼 :121-131 `_decided_yes` 가 `_decided` 로 답을 받아 `yes`/`no` 외에는 `_decision_failed` → exit 1. 호출 3곳(:396, :568, :730) 교체 | 1회차 head(8241bd5) 래퍼+wrapper.py 를 넣은 사본에서 test_f_a_failed_yes_no_decision_ends_the_submit_with_1 9건 **모두 실패** — subscriber-up 6건은 `assert 0 == 1`(codex 의 증상 그대로), stall-watch 3건은 오류 문구 없이 정상 종료. 현재 코드에서는 9건 모두 rc 1 + "could not decide" |
| claude N1(1회차): stall-watch 판정 실패가 `\|\| return 0` 으로 조용히 감시만 끔 | 위와 같음 — "no" 는 여전히 `return 0`(감시 안 함), 실패는 exit 1 | stall-watch-direct-{error,gone,empty} 통과 |
| agy: test_issue5_phase1_delegation·test_tier1_unit 의 동결 리터럴이 옛 `_decide sub-status`/`exit "$(_decide exit-code` 문자열을 가리킴 | `_decided job_status sub-status "$sub_rc"`, `_exit_for "$job_status"` 로 갱신 | 두 파일 통과(아래) |
## 2. 정상 경로 불변
- rc 형식은 그대로 남아 있다(`--print` 없이 subscriber-up 1 → rc 1 확인; test_u_yes_no_print 가 두 형식을 함께 고정).
- test_f_yes_and_no_keep_their_branches(direct·loop × up/not-up): "yes" 는 진행, "no" 는 "subscriber died early" 경로로 가고 direct 의 종료 코드는 0/1 — 옛 코드에서도 통과(4 passed)하고 새 코드에서도 통과한다 = 분기 결과 불변. 옛·새 비교 테스트(test_c_*) 전부 통과.
- `_decided_yes` 는 `if …; then`(:396, :568)과 `… || return 0`(:730) 안에서 직접 호출되며 서브셸이 아니므로 `exit 1` 이 래퍼를 끝낸다. `--print` 를 위치 인자 뒤에 붙여도 argparse 가 받는다(3.9 에서 직접 확인: `stall-watch remote:r claude --print` → stderr 진단 + stdout `no`, rc 0).
- `stall_watch` 의 진단 메시지는 전과 같이 stderr 로만 나가 stdout 의 한 줄 답을 더럽히지 않는다.
## 3. 정적 가드
test_s_no_printed_decision_is_read_raw 가 `_decide (subscriber-up|stall-watch)` 의 rc 읽기 재유입을 막고 `_decided_yes` 호출 수(2+1)를 고정한다. 옛 코드에서 실패, 새 코드에서 통과.
## 4. 이식성
`/bin/bash -n`(3.2.57) 통과. 추가된 bash 는 `case`·`local`·함수 호출뿐. wrapper.py 변경은 argparse `store_true` 와 print.
## 5. 테스트 실행 (검증 경로 …/2, 워크트리 .venv Python 3.9.6, `env -i` 로 HERDR_*·MAM_*·MQTT_*·WORKSPACE_ROOT 제거)
| 실행 | 결과 |
|---|---|
| test_issue45_restore, test_issue45_restore_unit, test_issue17_item4_decisions, test_issue33_budget, test_issue5_phase1_delegation, test_tier1_unit | **461 passed / 0 failed / 0 skipped** (537 s; 실서버 restore 5건 포함, herdr 0.9.3) |
| 새 테스트 19건, 1회차 head 코드 사본 | **15 failed** / 4 passed (실패 = yes/no 실패 9 + `--print` 단위 5 + 정적 가드; 통과 4 = 분기 불변 테스트, 의도대로) |
격리: 실행 전후 herdr 프로세스 차이는 오케스트레이터의 `agent wait` pid 교체뿐, `/private/tmp/m45*` 없음, `~/.config/herdr` sessions·config.toml 불변, 실제 .mam/jobs 항목 수 불변.
## 6. 비차단
- 1회차 N2(`_env` 가 상속 WORKSPACE_ROOT 를 비우지 않아 `_run_both` 4곳이 에이전트 pane 의 워크스페이스로 lib.sh 를 초기화할 수 있음)와 N3(herdr_restore.py docstring 줄바꿈)는 이 델타에서 다루지 않았다. 둘 다 이 PR 이 만든 문제가 아니며 그대로 비차단.
- 새 관찰 없음.
[VERDICT: PASS]
@@ -0,0 +1,65 @@
# Issue #60 리뷰 3차 — opencode 2.x 전용 지원 (job f1132474)
- 리뷰어: planner-reviewer-claude-01
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261018T073639Z-b5cb7f/1`
- identity: HEAD `0e05b1b6777ceb837066454ac688e04a0f5d14a0`, tree `c5cb1b16af283b3fdb447bafb865ba1abbfbb5a8` — 일치, 작업 트리 clean, `.mam` 없음. base..head 5커밋(a311c23, f0d1c00, 6b4b96f, cb5f439, 0e05b1b). 판정은 base..head 전체에 대해 내린다.
- 1차(6b4b96f)·2차(cb5f439)에서 실바이너리로 확인한 사항은 0e05b1b 가 건드리지 않은 제품 코드라 그대로 유효하다. 0e05b1b 는 status.sh, tests/_agent_home_guard.py, tests/test_issue60_opencode_v2.py 만 바꿨다. 확인한 사항:
- 측정 사실과 구현의 일치
- `session delete` 의 자식 삭제, cwd 무관, 공유 행 보존
- 3단 자손 정리
- 서비스 보유자 제외
- `--standalone` 수명
- resident children
- 실제 opencode 데이터는 읽기(`grep -c`, `stat`)로만 봤다.
## 결론
2차 차단 지적(B1)과 비차단 N1·N2 가 모두 해결됐고 새 회귀는 찾지 못했다. base..head 전체를 병합 가능으로 판단한다. 남은 것은 테스트 보강 권고뿐이다.
## 2차 지적별 확인
**B1 (XDG_DATA_HOME 테스트 격리) — 해결.**
- `_agent_home_guard.DROPPED_VARS` 에 `XDG_DATA_HOME` 이 추가됐다(`tests/_agent_home_guard.py:21`). 그래서 `conftest._isolate_agent_homes`(`conftest.py:210`)가 테스트마다 이를 지운다. mam_sandbox·issue31 `home` 처럼 HOME 만 바꾸는 fixture 도 이제 `<HOME>/.local/share` 를 본다.
- 2차에서 실패한 두 테스트(tier2 opencode e2e, `test_opencode_purge_leaves_no_rows`)가 `XDG_DATA_HOME=<scratchpad>` 에서 통과한다(아래 실행 결과).
- 뮤테이션: `DROPPED_VARS` 에서 다시 빼면 그 두 테스트와 `test_issue55::test_l1_every_path_variable_the_code_reads_is_accounted_for` 가 실패한다(3 failed). L1 은 환경과 무관하게 실패하므로, 이후 누가 지워도 잡힌다.
- 세션 가드는 `XDG_DATA_HOME` 이 설정돼 있으면 `<XDG_DATA_HOME>/opencode` 를 감시한다(`:48-49`). `ambient` 는 conftest 가 drop 전에 기록한 pane 값(`_AGENT_HOME_VARS = ("HOME",) + DROPPED_VARS`)이라 올바른 위치다.
**N1 (status.sh 고정 경로) — 해결.**
- 두 Python 블록(`status.sh:105-113`, `:305-313`) 모두 `get_adapter('opencode')._resolve_db(DiscoveryContext(workspace=cwd, agent_name='opencode', home_dir=home))` 를 쓴다.
- `cwd`(`:59`/`:259`)와 `home`(`HOME_DIR`, `:41`/`:241`)은 각 블록에 정의돼 있다. `env_python` 이 `PYTHONPATH=$SKILL_DIR` 를 넣고 환경을 상속하므로(`lib.sh:1852-1871`) `lib_py` import 와 PATH·XDG 가 그대로 전달된다. 같은 파일이 이미 `lib_py.delegation.logroot` 등을 import 한다.
- 행동 확인: git archive 사본에 임시 테스트를 넣고 실행했다(mam_sandbox+mock_herdr+mock_agents → create → reconcile 로 id 고정 → status). 1 passed.
- `--json` 의 `resume_state == "yes"` 와 plain 출력 행의 `yes`, 둘 다 rc 0.
- PATH 에서 opencode 를 빼도 폴백 경로로 `yes`.
- DB 를 `XDG_DATA_HOME` 쪽으로 옮기면, XDG 없이 `MISSING`, XDG 를 주면 `yes`.
- 행을 지우면 `MISSING`. 모두 rc 0.
- status 는 이제 opencode 행마다 `opencode debug paths` 를 실행한다. 이 명령은 opencode 자신의 log 디렉터리에 기록한다. 하지만 status 가 이미 돌리는 `reconcile.sh --dry-run` 의 drift C 도 같은 `_resolve_db` 를 부르므로 새로운 종류의 부작용은 아니다.
**N2 (`--version` 탐침이 실제 로그에 기록) — 해결.**
- `_real_opencode()`(`test_issue60_opencode_v2.py:290-303`)가 `TemporaryDirectory` 를 HOME·TMPDIR 로 하고 PATH 만 넘긴다. XDG_* 는 넘기지 않는다.
- 실제 `~/.local/share/opencode/log/opencode.log` 의 `args="[\"--version\"]"` 줄 수를 읽기로 세었다. 아래 두 스위트 실행 전 50, 후 50(증가 없음) 이다.
## 테스트 실행 (검증 경로, `env -i`, basetemp 는 scratchpad)
관련 10개 모듈: a4, issue31, issue10_13, issue60, tier2, issue55, issue57(status), issue21_worktree_prereqs(status), issue30_stall_watch, tier1.
- `XDG_DATA_HOME=<scratchpad>/jf1/xdg`, PATH 에 opencode 없음: **363 passed, 1 skipped**, rc 0. 실행 뒤 그 XDG 디렉터리는 비어 있었다(아무 파일·디렉터리도 생기지 않음).
- `XDG_DATA_HOME` 미설정, PATH 에 `~/.opencode/bin` 포함(실바이너리 계약 테스트 실행): **363 passed, 1 skipped**, rc 0.
- skip 은 두 번 모두 `/proc` 전용 테스트(macOS)다. #55 가드 오탐은 없었고, 실행 뒤 남은 opencode 프로세스도 없었다.
## 비차단 (테스트 보강 권고)
- **T1.** 이번에 바뀐 두 동작은 회귀 테스트가 없다. 뮤테이션(`XDG_DATA_HOME` 설정 상태, issue55·issue60·issue31·tier2):
- 가드의 XDG 감시를 원래대로 되돌림 → 28 passed, 실패 0
- status.sh 를 고정 경로로 되돌림 → 28 passed, 실패 0
`test_issue55::test_g1` 에 `watch_dirs({"HOME": h, "XDG_DATA_HOME": x})["opencode"] == x/opencode` 한 줄을 넣고, 위 임시 테스트와 같은 status 행동 테스트(XDG 쪽 DB → `yes`)를 넣기를 권한다.
- **T2 (기존 설계).** 가드는 opencode 데이터 디렉터리의 항목 이름을 비교한다. `XDG_DATA_HOME` 이 설정된 Linux 러너에서 사용자 opencode(서비스 포함)가 그 디렉터리에 새 항목을 만들면 #55 와 같은 종류의 오탐이 날 수 있다. 판정 근거로 쓰지 말고 보고만 할 종류다.
- 1·2차 비차단 N3(다른 MAM opencode 세션이 떠 있으면 무관한 purge 도 exit 4, lsof 없는 Linux 에서는 DB 보유자 검사 생략)는 의도된 동작으로 보고 그대로 둔다.
## 병합 가능성
- base..head 전체에서 `git diff --check` 는 깨끗하다.
- 변경 Python 파일은 Python 3.9(`/usr/bin/python3` 3.9.6)로 컴파일된다. 셸 스크립트는 `/bin/bash -n`(3.2)을 통과한다.
- 1·2차의 실바이너리 검증과 뮤테이션 결과에 이번 검증이 더해져, 차단 사항이 없다.
[VERDICT: PASS]
@@ -0,0 +1,14 @@
{
"loop_id": "loop-20261026T133440Z-c8961b",
"base_sha": "29cc4fd90478936f15387a7665e2c95d13a3f14c",
"head_sha": "5f6065344f800c2c5ad6f802e95e9e8ff5cc7b26",
"tree_sha": "98d57bdc60920238c115ece90c90cc694f2744bc",
"review_commit": "5f6065344f800c2c5ad6f802e95e9e8ff5cc7b26",
"reviewer": "planner-reviewer-claude-01",
"job_id": "f39ae4ba",
"verdict": "PASS",
"transport": "herdr",
"evidence": "review-tree",
"isolation": "worktree",
"verified_at": "2026-10-26T17:32:11Z"
}
@@ -0,0 +1,311 @@
# PR #73 (Issue #71) 리뷰 — head 5f60653
- 리뷰어: planner-reviewer-claude-01 (macOS Darwin 25.6, `/bin/bash` 3.2.57, `/usr/bin/python3` 3.9.6, BSD sed/tr)
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2`
- identity 확인 (시작·종료 시 모두):
- HEAD 5f6065344f800c2c5ad6f802e95e9e8ff5cc7b26, tree 98d57bdc60920238c115ece90c90cc694f2744bc
- `git status --porcelain --ignored` 비어 있음
- `.mam` 없음. 추적 파일 `.mam.env.example` 만 있다.
- main HEAD 29cc4fd 그대로.
- 범위: 29cc4fd..5f60653, 커밋 14개, 파일 28개 (+953/−176).
- 읽은 것: 이슈 #71 본문과 댓글("범위 정리" 1–10항, #72 분리), PR #73 본문.
## 0. 이번 잡(f39ae4ba)에 대해
- 이 잡은 같은 루프의 검증 경로 `/2` 다. head 5f60653, tree 98d57bdc 는 앞 잡 0698ffc5(검증 경로 `/1`) 와 같다. 브리프의 확인 항목도 잡 ID와 경로를 빼면 글자까지 같다.
- 그래서 아래 1장의 분석(파일:줄 근거, 리뷰어 시험 p1–p12, 변이 m1–m12, base 비교)은 같은 tree 의 `git archive` 사본에서 얻은 결과를 그대로 쓴다.
- 이번 경로 `/2` 에서 새로 확인한 것:
- 브리프에 붙은 diff(1920줄)가 `git diff 29cc4fd 5f60653` 와 바이트까지 같다. 커밋은 14개다.
- `/2` 의 `lib.sh` 에서 `_sanitize_herdr_agent_name` 을 다시 뽑았다. 정의는 하나이고, 앞서 비교한 정의와 같다.
- `/2` 의 `lib_py` 를 기준으로 bash↔Python 비교를 다시 돌렸다. 408건에 불일치는 0이다. python3 와 해시 도구가 모두 없는 5건도 기대대로 동작했다.
- 테스트 세 묶음을 `/2` 에서 다시 돌렸다. 결과는 아래 2장 표와 같다.
- issue71+issue66: 77 passed
- A: 384 passed, 1 skipped
- B: 543 passed, 3 skipped
- 실제 herdr 서버 두 개(pid 11655, 69309)는 실행 전후로 같다.
- 시작과 끝에 `/2` 의 HEAD·tree 를 확인했다. `status --ignored` 는 비어 있고 `.mam` 은 없다. main HEAD 는 29cc4fd 그대로다.
## 1. 확인한 것과 근거
### 1) 완료 조건과 범위
| 항목 | 결과 | 근거 |
|---|---|---|
| 1 attach 형태 | 충족 | 아래 3) |
| 2 `default` 도 적음 | 충족 | 아래 3) |
| 3 래퍼 attach | 충족 | 아래 3) |
| 4 두 필드 삭제 | 충족 | 아래 4) |
| 5 원칙(스킬로 시작·종료), 행 없는 stop | 충족 | 아래 4), 5) |
| 6 attach 는 예외 | 충족 | attach 만 herdr 명령을 직접 쓴다 |
| 7 변환 규칙 하나 | 충족 | 아래 6) |
| 8 해시 불가 시 오류 | 충족 | 아래 6) |
| 9 마이그레이션 | 충족 | 아래 7) |
| 10 stall_watch 한 곳 | 충족 | 아래 8) |
- 보강 완료 조건도 모두 충족한다.
- 새 행에 두 필드가 없다.
- 해시 도구가 없으면 python3 로 같은 결과를 내고, python3 도 없으면 오류다.
- 옛 행의 마이그레이션은 멱등이고, YAML 과 DB 가 같다.
- 행 없는 stop 은 레지스트리를 바꾸지 않는다.
- 정리 안내는 stop 스킬 명령이다.
- #72(update 스킬)는 끌어들이지 않았다.
- 진입점 `python3 -m lib_py.registry_format` 만 두었다.
- 그 진입점을 부르는 코드는 없다.
- 본문 완료 조건 6(실제 에이전트를 32자 초과 이름으로 띄워 attach)은 수동 확인 항목이다. 브리프 제약(실제 에이전트·실제 herdr 금지)에 따라 하지 않았다.
- 대신 형태가 맞는지는 확인했다. 실제 herdr 0.9.5 의 `agent attach --help` 를 격리 HOME 에서 보면 사용법이 `herdr agent attach <TARGET> [OPTIONS]` 이고, 옵션은 `--takeover` 다.
### 2) 회귀 테스트 tests/test_issue71_herdr_names.py (70개)
- head (검증 경로): issue71 70개와 issue66 7개를 함께 돌려 77 passed.
- base: 29cc4fd 사본에 head 의 테스트 파일을 넣었다. conftest 는 이 PR 에서 바뀌지 않았다.
- 결과: **68 failed, 2 passed**. PR 이 말한 숫자와 같다.
- 통과하는 2개:
- `test_the_names_need_converting_and_the_plain_one_does_not`: 변환 전제를 확인한다.
- `test_wrapper_attach_calls_herdr_with_the_herdr_name[PLAIN]`: 변환이 필요 없는 이름이라 base 에서도 맞다.
- reconcile 경로 (`test_reconcile_registers_with_the_attach_command`, test :126-139) 는 행 등록 경로에 실제로 닿는다.
- mock herdr 에 `cwd=mam_sandbox`, `pane_id` 가 있는 agent 를 넣는다.
- 실행 전에 행이 없음을 확인한다 (:132).
- 실행 뒤 `last_visible_note == "auto-registered by monitor"` 인 행이 하나인지 확인한다 (:137-138).
- 변이 m10 (reconcile 을 옛 형태로) 이 이 테스트 3개를 실패시킨다.
### 3) attach_command (`lib_py/agents/sanitize.py:34-43`)
- 형태: `herdr --session <shlex.quote(server or "default")> agent attach <shlex.quote(sanitize(name))>`.
- 서버가 None 이나 빈 값이어도 `--session default` 가 된다.
- 변환된 이름은 `[a-z0-9_-]` 이므로 quote 를 해도 그대로다.
- 서버 `srv x` 는 `'srv x'` 로 감싸진다. 내 시험과 test :51 에서 확인했다.
- 쓰는 곳은 네 곳이고, 모두 이 함수를 쓴다:
- create: `create_session.sh:566`
- resume 의 새 행: `update_yaml_resumed.sh:151`
- resume 의 서버 변경: `update_yaml_resumed.sh:161`
- reconcile: `reconcile.sh:676`
- 마이그레이션도 같은 함수를 쓴다 (`registry_format.py:62`).
- resume 의 else 분기는 서버가 바뀔 때만 attach_command 를 다시 쓴다 (:159-161).
- 서버가 그대로인 기존 행은 첫 쓰기의 마이그레이션이 이미 새 형태로 바꿔 둔다. 그래서 낡은 값이 남지 않는다.
- lib.sh 래퍼 (`lib.sh:605-613`):
- `agent attach <t> ...` 의 첫 인자를 `_resolve_herdr_target` 으로 변환한다. get/read/prompt 와 같은 함수다.
- 나머지 `"$@"` 는 그대로 넘긴다.
- 테스트 :151-159 가 strict mock 에서 `["agent","attach",<herdr 이름>,"--takeover"]` 를 확인한다.
- 변이 m4 (원래 이름을 넘김) 는 LONG·UPPER 에서 잡힌다.
### 4) kill_command·start_command 삭제
- 코드·문서에는 이 두 키가 남아 있지 않다. 리뷰 트리 전체를 grep 했다.
- 남은 곳: 마이그레이션 상수 (`registry_format.py:37`), 테스트의 "옛 행" 픽스처와 부재 단언, 과거 리뷰 보관본 `.agents/reports/*`.
- `status --json` 의 `start_command` 키는 빠졌다 (`status.sh:224-228`).
- 이 키를 읽는 코드나 테스트는 없다.
- status SKILL.md 의 예시도 같이 고쳤다.
- 래퍼의 `new-session`/`kill-session` 가짜 명령은 남겨 두는 것이 맞다. 아직 쓰는 곳:
- create (`create_session.sh:266-276`, :346)
- stop (`stop_session.sh:157`, :307, :321)
- reconcile
- 사용자에게 보이던 kill-session 안내 두 곳은 stop 스킬 명령으로 바뀌었다:
- create exit 43 (`create_session.sh:364`)
- id_order (`id_order.py:343-345`)
- 바뀐 명령 `stop_session.sh --session X --herdr-session S` 는 행이 없어도 동작한다 (아래 5).
- exit 43 은 `_mark_row_rolled_back "running" "rollback-failed"` 로 행을 남기므로, 그 경우는 행 있는 stop 경로를 탄다.
### 5) 행이 없는 stop (`stop_session.sh:117-191`)
- **행이 없을 때만 그 경로로 간다.**
- 행을 찾는 Python 은 행이 없을 때만 `sys.exit(7)` 을 낸다.
- `[ "${MAPPED_RC:-0}" -ne 7 ] || stop_rowless` (:189) 로 7 일 때만 row-less 로 간다.
- 다른 실패는 `cannot read the row` 로 exit 1 이다 (:190-193).
- 변이 m6 (0 이 아니면 모두 row-less) 은 `test_stop_does_not_take_an_unreadable_row_for_no_row` 에 잡힌다.
- **레지스트리를 읽지 못할 때**는 `load_state_json strict` (:170, lib.sh:1598-1631) 가 exit 1 로 끝난다.
- 해당 경우: YAML 깨짐(DB 없음), DB 손상, sessions 테이블 없음, 테이블 전부 없음.
- 테스트 :514-541 의 4개 매개변수가 이를 확인한다.
- 인자 없는 기존 호출은 동작이 같다. strict 일 때만 raise 하거나 exit 한다.
- **작업 공간 판정** (:142-151) 은 `realpath` 두 개를 비교한다: `cwd == root or cwd.startswith(root + os.sep)`. 내 추가 시험 (head 사본, `tests/test_zz_review71.py`) 결과:
| 경우 | 결과 |
|---|---|
| p1 접두어 혼동 (`<ws>-other`) | exit 5, 닫지 않음 |
| p2 밖에서 작업 공간 안으로 가는 심볼릭 링크 | 0, 닫음 |
| p3 작업 공간 안에서 밖으로 가는 심볼릭 링크 | exit 5 |
| p4 후행 슬래시 | 0 |
| p5 WORKSPACE_ROOT 를 심볼릭 링크로 지정 | 0 |
| p7 32자 초과 이름 (strict mock) | 0 |
- **서버 결정 순서** (:107-113, :128-130) 는 `--herdr-session` → 환경의 `HERDR_SESSION_NAME` → 작업 공간 서버다.
- 환경 값이 비었거나 `default` 이면 작업 공간 서버로 간다. create 의 :185, :253 과 같은 규칙이다.
- p6 (환경 값 `default`) 에서 모든 `agent get` 이 작업 공간 서버로 간 것을 확인했다.
- 테스트 :487-499 는 환경 서버를 확인한다.
- **레지스트리를 바꾸지 않는다.** 모든 row-less 테스트가 YAML/DB 해시를 비교한다 (`_registry_bytes`).
- **`--purge-conversation`** 은 행이 없으면 exit 1 이다 (:124-126, test :544-550).
- **종료 코드:**
- 0 / 1 / 5 / 6 은 SKILL.md 표 (:58-67) 와 헤더 주석 (:30-36), 코드 (:134, :154, :161, :163) 가 같다.
- `test_literal_exit_rule_on_base_scripts` 는 `{0..6}` 이다.
- skill_code_blocks 의 `entry_points` 도 5·6 을 더했다.
### 6) 이름 변환 (`lib.sh:44-82`)
- 정의는 하나다 (`lib.sh:44`). test :171 이 확인한다.
- shim 은 `{ printf shebang; declare -f _sanitize_herdr_agent_name; cat <<'EOF' … } > "$tmp_file"` (:179-1393) 으로 같은 정의를 받는다.
- bash 3.2 의 `declare -f` 출력을 다시 source 한 사본도 아래 비교에 넣었다.
- 이 호스트의 `bash` 는 `/bin/bash` 3.2 하나뿐이라, 테스트의 shim 도 3.2 로 돈다.
- has-session 의 `lambda s: s.lower()[:32]` 대체와 `os.path.abspath(".agents/skills")` import 는 없어졌다.
- 대신 lib.sh 가 계산한 `TARGET_SAN` 을 넘긴다 (:633-641).
- **macOS 실측 비교:** 이름 34개를 Python `sanitize_herdr_agent_name` 과 비교했다.
- 이름: ASCII, 대문자, 32/33/40/200자, 숫자·`_`·`-n`·`-e`·`--help` 로 시작, `\n`(가운데·앞·뒤·연속), `\t`, `\r`, `*?[a]`, `\`, `%s`, `Ü`, `İstanbul…`, `日本語`, 이모지, `K`(U+212A), `ß`, `Dž`, `Ω…` 긴 이름, NBSP 등.
- 원본 정의와 `declare -f` 사본 2 벌 × 환경 6 개 (`LANG=en_US.UTF-8`, `LC_ALL=C`, 로캘 없음, `LC_ALL=UTF-8`+`LC_CTYPE=C`, 해시 도구 없이 python3 만 + UTF-8 또는 C) = **408 건, 불일치 0**.
- ASCII 가 아닌 이름은 바이트 단위 치환을 쓰지 않고 python3 의 문자 단위 `str.lower()`+치환을 쓰므로 Python 과 같다 (:51-56).
- **해시 도구별:** `/sbin/sha1sum` 만, openssl(LibreSSL) 만, 깨진 shasum(빈 출력)에서 openssl 로 넘어가는 경우 모두 Python 과 같았다 (`mam-wt-issue-71-very-lo-54e536a7`).
- **python3 도 해시 도구도 없을 때:**
- 짧은 ASCII 이름(대문자, 끝의 `\n` 포함)은 정상이다.
- 32자 초과는 `no shasum, sha1sum, openssl or python3` 오류로 rc 1 이다.
- ASCII 가 아닌 이름은 `not ASCII and there is no python3` 오류로 rc 1 이다.
- `00000000` 은 어디에도 없다.
- 호출하는 곳은 실패를 넘긴다: `|| return 1`, `|| exit 1`, `wait_for_agent_idle` 은 `|| return 3`, delegate-job 은 진단만 건너뛴다.
- spawn 의 `agent_name` 계산은 루프 앞 (:688) 으로 옮겨졌다. 그래서 이름을 못 구하면 spawn 하지 않는다. test :267 이 확인한다.
### 7) 레지스트리 형식 2 (`registry_format.py`, `atomic_yaml.py:118-137, :192`)
- **순서:** 잠금 → `BEGIN IMMEDIATE` (:79-87) → DB 또는 YAML 읽기 → 마이그레이션 (:121-126) → 호출자 변경 (:144-149) → 검증 → DB 쓰기 → commit → YAML 쓰기.
- 마이그레이션은 잠금 안에서, 호출자 변경 전에 돈다.
- **모든 행을 바꾼다.** `migrate` 는 이름이 있는 모든 행에서 두 키를 지우고 attach_command 를 다시 만든다.
- stopped·terminated 도 포함한다. 변이 m8 (running 만) 은 6 failed 로 잡힌다.
- **YAML 도 반드시 다시 쓴다.** `force_yaml = True` (:126) 이고, 변이 m2 는 6 failed 로 잡힌다.
- **멱등이다.** test :327-338 이 확인하고, 진입점을 두 번 부르면 `2 -> 2, rows changed: 0` 에 바이트도 같다 (test :342-362).
- **YAML 교체가 실패한 뒤의 복구:** DB 가 2 이고 YAML 이 2 미만이면 다음 쓰기에서 `force_yaml` 이 된다 (:127-137).
- 진입점으로 하든 아무 쓰기로 하든 복구된다 (test :379-411).
- 변이 m3 은 2 failed 로 잡힌다.
- **다른 쓰기 경로:** 레지스트리 YAML/DB 를 쓰는 코드는 `atomic_yaml` 하나뿐이다.
- grep 결과 `safe_dump`, `INTO state/sessions` 는 이 파일에만 있다. hermes.py 의 DELETE 는 에이전트 자신의 DB 다.
- orc_onboard 와 autopin 도 `atomic_dump_yaml(_main)` 을 거친다.
- 호출자 변경 가운데 `d` 를 통째로 새로 만드는 것은 없다. 만들어도 다음 쓰기가 다시 마이그레이션하므로 형식 키를 잃지 않는다.
- **읽기 경로:** `load_state_json` 과 `lib_py.state` 는 바꾸지 않았다. strict 인자만 더했다.
- **미래 형식 (p8):** `registry_format: 3` 이면 진입점은 `3 -> 3, rows changed: 0` 이다. 쓰기 뒤에도 키는 3 으로 남고, 행(옛 키 포함)도 그대로다. 내려 쓰지 않는다.
- **비정상 값 (p9):** `"2"`, `True`, `None` 은 1 로 보고 2 로 바꾼다. `0` 은 `0 -> 2` 로 출력된다.
- **진입점 계약:**
- 성공은 stdout 한 줄 `registry-format: <from> -> <to>, rows changed: <n>` 에 exit 0 이다.
- 레지스트리가 없으면 exit 1 이다 (test :423).
- DB 손상 (p10) 은 stderr `registry-format: error: file is not a database` 에 exit 1 이고 stdout 은 비어 있다.
- 검증 거부 (p11) 는 `registry-format: error: VALIDATE: … bad status` 에 exit 1 이다.
- 모두 docstring 과 같다.
- **macOS persistent WAL:** YAML/DB 바이트 비교 테스트(진입점 재실행, row-less stop)가 이 호스트에서 모두 통과했다.
### 8) stall_watch
- `StallWatch.__init__` 이 `self.herdr_name` 을 한 번 구한다 (:229).
- 그 값을 `HerdrSource(..., name=self.herdr_name)` (:230) 와 `wait_once` (:332) 에 같이 쓴다.
- `HerdrSource` 는 keyword-only `name` 이 필수다. 원래 이름으로 대신 쓰는 경로는 없다.
- 다른 호출자는 `test_issue60_linux_fixes.py:99` 하나이고, `name="t"` 를 넘기도록 바뀌었다.
- `tests/test_issue66_stall_watch_sanitized_name.py` 는 diff 가 없고, 7 passed 다.
### 9) 바뀐 기존 테스트
- `test_tier2_component`: `HERDR_SESSION_NAME=` 포함 여부 대신 새 attach 형태 전체가 같은지와 두 키가 없는지를 본다.
- 서버 전파와 라벨이 새지 않는지를 보는 원래 의도는 유지되고, 단언은 더 엄격해졌다.
- `test_issue50_pane_lock:173`: create 가 쓴 행이라는 증거를 `start_command` 대신 `session_id_source` 로 본다.
- reconcile 의 행 (`reconcile.sh:666-680`) 에는 이 키가 없다. 그래서 증거로서 같은 구분을 한다.
- `test_issue21_worktree_prereqs:584`: 제어 루트 경로가 새지 않는지를 attach_command 에서 본다. 의도가 같다.
- `test_tier1_unit` / `test_tier3_integration`:
- "agent 를 추론할 수 없으면 exit 2" 를 이제 **행이 있는** 세션으로 확인한다. 행이 없으면 row-less 경로로 가기 때문이다.
- 의도가 유지된다.
- `test_issue63`, `test_issue23_24` S9: 안내 문구를 새 명령으로 바꿨다.
- `test_layout`, `test_issue42`: heredoc 시작 위치와 종료 코드 집합을 따라 고쳤다.
- `tests/data/skill_code_blocks.json`:
- 블록 33개 가운데 sha 가 바뀐 것은 문서 내용이 바뀐 두 블록이다: create :134 (YAML 행 예시), status :74 (JSON 예시).
- 나머지는 줄 이동과 evidence 줄 번호다.
- `entry_points` 의 stop 종료 코드에 5·6 을 더했다.
- 문서 diff 와 맞다.
- **S11** (`test_issue23_24_startup_path.py:294-316`):
- 비교 기준을 `0fc162e` 로 두었다. `0fc162e..HEAD` 사이 lib.sh 변경은 5f60653 의 `load_state_json` 하나뿐이고, shim heredoc 과 sanitize 함수는 바뀌지 않았다.
- merge commit 으로 병합하면 0fc162e 가 main 이력에 남으므로 계속 맞다.
- 기준을 찾지 못하면 `pytest.skip` 한다. 이는 base(525df2b) 때부터의 동작이다. git 이 없는 설치본이나 squash 병합에서 건너뛰는 것은 기존 설계 그대로라고 본다.
- 검증 경로(git worktree)에서는 **skip 이 아니라 통과**했다 (`-k s11`: 1 passed).
- squash 하면 이 확인이 사라지므로 PR 본문의 "merge commit 필수" 를 지켜야 한다.
### 10) macOS 호환
- 바뀐 Python 6개 파일이 `/usr/bin/python3` 3.9.6 에서 compile 된다.
- `attach_command` 와 `migrate` 가 3.9 에서 실행된다.
- 바뀐 셸 줄에 `declare -A`, `mapfile`, `${x,,}`, `sed -i`, `stat -c`, `readlink -f`, `local -n` 이 없다.
- 이름 변환은 BSD tr/sed 와 bash 3.2 에서 위 6) 처럼 확인했다.
## 2. 실행한 테스트
- 실행 조건:
- 모두 `env -i PATH=/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin`, 임시 HOME/XDG_DATA_HOME 에서 돌렸다.
- `PYTHONDONTWRITEBYTECODE=1 -p no:cacheprovider --basetemp=<스크래치>` 를 썼다.
- `.mam.env` 는 export 하지 않았다.
- 실제 herdr 서버 두 개는 테스트 전후로 같다: pid 11655 `herdr server`, 69309 `herdr --session multi-agent-mux server`, 시작 시각도 같다.
| 대상 | 결과 |
|---|---|
| 검증 경로: test_issue71_herdr_names + test_issue66_stall_watch_sanitized_name | **77 passed** (`/1`, `/2` 모두) |
| base 29cc4fd 사본 + head 의 issue71 테스트 | **68 failed, 2 passed** |
| 검증 경로 A: tier1_unit, tier2_component, tier3_integration, issue23_24_startup_path, issue50_pane_lock, issue21_worktree_prereqs, issue63_same_cwd_ids, issue60_linux_fixes, layout, issue42_skill_blocks | **384 passed, 1 skipped** (`/1`, `/2` 모두; skip: issue42 의 `.claude/skills is untracked; absent in a worktree`, 환경 요인) |
| 검증 경로 B (stop·reconcile·create·resume 인접): b4_session_created, b19_headless_reconcile_fixes, c1_tui_readiness, issue15_registry_isolation, issue17_workspace_root_isolation, issue31_purge_residual, issue24_resume_dialogs, issue40_auto_pin, issue45_restore, issue6_fail_closed, issue9_server_env, issue64_ro_connect, uuid_target, workspace_scope, issue57_logs_follow_registry, issue30_stall_watch, issue30_stall_watch_stop, issue50_dispatch_herdr, issue60_opencode_v2, issue36_prompt_transport, a4_adapter_contract, issue26_ints | **543 passed, 3 skipped** (`/1`, `/2` 모두) |
| S11 단독 (검증 경로) | 1 passed (skip 아님) |
| 리뷰어 추가 시험 (head 사본, p1–p12) | 14 passed. 1개는 내 기대값 오류다 (형식 0 → "0 -> 2" 출력, 동작은 정상) |
| bash↔Python 이름 변환 비교 | 408 건 불일치 0, 도구 없음 5 건 모두 기대대로 (`/2` 의 lib.sh·lib_py 로 다시 실행) |
변이 (head 사본, issue71 70개):
| # | 변이 | 결과 |
|---|---|---|
| m1 | 작업 공간 판정을 `startswith(root)` 로 (접두어 혼동) | **살아남음** (리뷰어 p1 은 잡음) |
| m2 | 마이그레이션 뒤 YAML 강제 쓰기 끔 | 6 failed |
| m3 | YAML 복구 분기 끔 | 2 failed |
| m4 | 래퍼 attach 에 원래 이름 | 2 failed |
| m5 | 줄바꿈 → `-` 제거 | 6 failed |
| m6 | rc≠0 이면 모두 row-less | 1 failed |
| m7 | strict 에서 state 테이블 오류만 무시 | **살아남음** (아래 N3) |
| m8 | running 행만 마이그레이션 | 6 failed |
| m9 | 서버 없으면 `--session ''` | 3 failed |
| m10 | reconcile 을 옛 attach 형태로 | 3 failed |
| m11 | 행 없는 stop 이 환경 서버를 무시 | 1 failed |
| m12 | 해시 실패 시 `00000000` | 4 failed |
## 3. 차단 결함
없음.
## 4. 비차단
### N1: 행 없는 stop 이 제어 루트 밖 워크트리의 세션은 닫지 못한다
- 상황:
- `worktree_prepare.sh:229-236` 은 creator 를 `WORKSPACE_ROOT=$MAIN` 과 `--workspace $WT` 로 만든다.
- `$WT` 는 `--path` 로 받은 임의의 경로이고, 보통 제어 루트 밖이다. 루프의 `mam-wt-*` 나 `$TMPDIR/mam-review/…` 가 그 예다.
- 결과:
- 그런 create 가 행을 등록하기 전에 죽으면, id_order 의 안내 (`id_order.py:343-345`) 는 stop 명령을 준다.
- 그 stop 은 `realpath(cwd)` 가 `WORKSPACE_ROOT` 밖이므로 exit 5 로 거부한다 (`stop_session.sh:151-154`). 다른 작업 공간 테스트 (test :467) 와 같은 경로다.
- 거부는 안전하다(아무것도 바꾸지 않음). 하지만 이 경우에는 안내된 스킬 명령으로 정리할 수 없다.
- 권고: 둘 중 하나.
- (a) row-less 판정에 "이 제어 루트에 묶인 워크트리(`git worktree list`)" 도 넣는다.
- (b) exit 5 메시지에 그 경우의 정리 방법을 적는다.
### N2: 접두어 혼동을 고정하는 테스트가 없다
- m1 이 PR 테스트에서 살아남는다. `<ws>-other` 를 cwd 로 하는 한 줄짜리 시험을 더하기를 권한다. 내 p1 이 그 형태다.
### N3: strict 읽기에서 state 테이블만 없는 DB 는 걸리지 않는다
- m7 이 살아남은 이유는 테스트의 `no-tables` 가 sessions 테이블 오류로도 잡히기 때문이다.
- state 만 없는 DB 에서도 stop 은 sessions 테이블로 행을 정확히 찾으므로 영향은 없다.
### N4: 이 PR 이 건드리지 않은 attach 안내가 남아 있다 (모두 base 부터 있던 것)
- `create_session.sh:770`: `Attach: herdr session attach $SESSION_NAME`. 서버를 적지 않고, 이름도 변환하지 않는다. 행의 `attach_command` 를 출력하기를 권한다.
- `multi-agent-mux-delegate-job:1021`: `HERDR_SESSION_NAME=… herdr agent attach $sess (lib.sh를 source한 셸에서)`. 래퍼가 이제 이름을 변환하므로 동작은 하지만, 새 형태와 다르다.
- `deploy/install_mam.sh:330`: `HERDR_SERVER_NAME=… herdr agent attach`. 없는 환경 변수 이름이다.
### N5: 래퍼 attach 는 대상이 첫 인자라고 가정한다
- `herdr agent attach --takeover <name>` 순서로 쓰면 래퍼는 `--takeover` 를 대상으로 찾다가 exit 1 을 낸다.
- herdr 의 사용법(`<TARGET> [OPTIONS]`)과 문서의 순서대로라면 문제가 없다.
### N6: YAML 이 아직 없는 작업 공간에서는 행 없는 stop 이 동작하지 않는다
- 처음 만든 작업 공간에서 첫 create 가 행 등록 전에 죽어 YAML 이 아직 없으면, stop 은 행 없는 경로 전에 `YAML not found` 로 exit 1 한다 (:101).
- 드문 경우다.
### N7: 정리할 만한 작은 것들
- `create_session.sh:519` 의 `server_opt` 는 쓰는 곳이 없다. base 에서도 그랬다.
- shim 내용이 바뀌었으므로, 같은 제어 루트를 쓰는 옛 버전 체크아웃과 새 버전 체크아웃이 섞이면 `.mam/shim/herdr` 를 서로 덮어쓴다. #45 때와 같은 전환기 비용이다.
[VERDICT: PASS]
@@ -0,0 +1,79 @@
# Issue #60 리뷰 — opencode 2.x 전용 지원 (job f4cedbae)
- 리뷰어: planner-reviewer-claude-01
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261018T055915Z-abe16e/1`
- identity 확인: HEAD `6b4b96fa1875862ef3bed830fe7b04166b1fad58`, tree `9cfb2106081eba96b58cc6b7573031de0acba399` — [리뷰 identity] 와 일치, 작업 트리 clean, `.mam` 없음. base..head 커밋 3개(a311c23, f0d1c00, 6b4b96f).
- 실제 opencode 데이터·서비스는 건드리지 않았다. 실제 경로는 `ls`·`lsof -t`·`ps` 로만 봤다. 실제 바이너리(v2.0.32)는 전부 `HOME=<scratchpad>/j60/h*`, `TMPDIR=<scratchpad>/j60/t*`, `XDG_*` unset, `--standalone`(또는 `debug paths`·`--help`)로만 실행했고, 모델 호출 프롬프트는 보내지 않았다. 각 실험 뒤 `ps` 로 남은 opencode 프로세스가 없음을 확인했다(사용자 서비스 pid 86524 `opencode serve --service` 제외).
## 결론 요약
구현은 측정 사실과 맞고, 실제 2.0.32 바이너리로 직접 재현한 결과도 일치한다. 차단 결함은 찾지 못했다. 테스트는 회귀를 잘 잡는다(뮤테이션 13개 중 12개 검출). 아래 비차단 항목 중 N1(opt-in 실바이너리 테스트의 PATH 의존)은 Linux 호환성 시험 전에 고치기를 권한다.
## 테스트 실행 (검증 경로, `env -i` + PATH 에 `~/.opencode/bin` 포함, basetemp 는 scratchpad)
- 관련 11개 모듈(`test_a4_adapter_contract`, `test_issue31_purge_residual`, `test_issue10_13_purge`, `test_issue60_opencode_v2`, `test_tier1_unit`, `test_tier2_component`, `test_issue5_phase0_contracts`, `test_issue23_24_startup_path`, `test_issue6_fail_closed`, `test_c1_tui_readiness`, `test_issue9_server_env`): **420 passed in 723.50s**, rc 0 (opt-in 실바이너리 계약 테스트 포함; #55 가드 오탐도 이번에는 없음). 실행 뒤 남은 opencode 프로세스 없음.
- `test_issue60_opencode_v2.py` 를 PATH 에 opencode 없이 실행: 1 failed, 9 passed — 아래 N1.
## 1. 측정 사실 ↔ 구현 대조 (실제 opencode 2.0.32, 임시 HOME 에서 재현)
| 항목 | 직접 측정 | 구현 |
|---|---|---|
| `debug paths` | exit 0, 0.04 s, `db <HOME>/.local/share/opencode/opencode.db` 줄 포함(공백 정렬). DB 는 만들지 않음(`log/`·`repos/` 디렉터리만 생성) | `opencode.py:93-98` 이 `split(None, 1)` 로 `db` 줄을 읽고 `strip()` — 경로의 공백도 보존됨. 실패·타임아웃(5 s) 시 기본 경로 폴백(`:101`) |
| TUI 플래그 | `--help` 에 `--standalone`·`--auto`·`--session, -s` 있음, `--agent`·`-m` 없음. `--session` 은 "continue, or to create if it does not exist" | `spawn_spec`/`resume_spec`(`:198-204`), create/resume/update_yaml 의 셸 사본 모두 `--auto --standalone` |
| 세션 저장소 | 새 2.x DB 에 `session`·`message`·`part` 테이블 없음, 세션은 `session_v2`(`parent_id`, `directory`=realpath, `time_created` ms) | verify/discover/_has_session/status/reconcile 모두 `session_v2` |
| `session delete --standalone -- <id>` | 부모 P 삭제 시 자식 C 의 `session_v2`·`session_message`·`event_sequence` 행까지 삭제, 같은 디렉터리의 다른 세션 O 는 그대로. 프로젝트의 `event_sequence`(aggregate_id=project id) 같은 공유 행은 남음. **cwd 와 무관**(다른 프로젝트 cwd 에서 지워도 동작). 없는 id → stderr `Session not found: <id>`, exit 1 | `purge_artifacts`(`:136-150`) 가 `session_v2` 에 있을 때만 CLI 호출, `--` 사용, 실패 시 `WARN` + `residual` 에 `session_v2.id` 보고(`:192-193`). 가짜 CLI(`tests/_fake_opencode.py`)의 출력 스트림·exit 코드·자식 재귀 삭제가 실제와 같음 |
| `--standalone` 수명 | pty 안에서 TUI(`opencode --auto --standalone`)가 자식 `opencode serve --stdio --port 0` 을 띄움. 시작만으로는 `session_v2` 에 행이 생기지 않음(첫 메시지 전). `/exit`(입력 후 1 s 뒤 Enter) → 종료, pty 닫힘 → 0.11 s 안에 종료, TUI SIGKILL → 서버 자식도 2 s 안에 종료. 모든 경우 남은 프로세스 0 | stop 의 graceful(`/exit` via send_keys_safe)→kill-session→SIGKILL 체인은 변경 없음이며 어느 단계에서도 프로세스가 남지 않는다 |
참고: 브리프의 "TUI 는 SIGTERM·SIGHUP 을 무시" 는 내 pty 실험에서는 재현되지 않았다(SIGTERM 한 번에 TUI·서버 모두 종료). 어느 쪽이든 stop 체인에는 유리한 방향이라 판정에 영향 없음.
## 2. 코드 리뷰
- `opencode.py:62-87` `_bin`/`_run`: 기존 `_resolve_db` 안의 탐색 로직을 그대로 뽑아낸 것. `_run` 은 HOME·XDG_DATA_HOME 을 ctx.home_dir 기준으로 덮어써 `debug paths` 와 `session delete` 가 같은 DB 를 보게 한다(일관). `cwd` 가 없으면 None — `session delete` 가 cwd 무관함을 실측했으므로 문제 없음.
- `verify_artifact`(`:107-131`): `session_v2` 의 `directory, time_created` 고정 스키마. ms→s 변환, epoch 비교, workspace_key 비교 유지.
- `discover`(`:213-239`): `parent_id IS NULL` — 실제 스키마에서 subagent 세션은 `parent_id` 가 채워지고 `directory` 가 같으며 더 늦게 생성됨을 확인(C 가 P 보다 161 ms 뒤). 필터가 없으면 reconcile 이 두 후보로 `C-ambiguous` 가 되거나 자식을 고를 수 있으므로 정당하다.
- `purge_artifacts`: `_has_session` 이 거짓이면(1.x 에만 있는 세션, 없는 id) CLI 를 부르지 않는다. 1.x 테이블 SQL 삭제는 `session_id`/`id` 정확 일치만이라 다른 세션·공유 행을 지우지 않는다. 1.x 테이블이 없으면 "sqlite rows" 항목을 보고하지 않도록 바뀐 것(`:172-173`)도 맞다.
- reconcile drift C(`reconcile.sh:952-994`): `PRAGMA table_info(session_v2)` + `parent_id IS NULL`(컬럼 있을 때만). `_resolve_db` 가 이제 항상 경로를 돌려주므로 `:916-930` 의 파일시스템 폴백은 사실상 죽은 코드(기존 코드, 비차단).
- status.sh 두 곳 `session_v2`. Python 3.9(`/usr/bin/python3` 3.9.6, venv 3.9.6) 파싱 OK, 셸 5개 `/bin/bash -n`(3.2) OK, `git diff --check` 깨끗.
- 남은 1.x 가정 검색(`agent build`, `db path`, `FROM session\b`, `table_info(session)`, `opencode -m`; VERSIONS.md·bug-report·.agents/reports 제외): 코드·문서에 남은 곳 없음(주석·테스트 docstring 의 설명적 언급만).
## 3. 테스트가 회귀를 잡는가 — 뮤테이션 (git archive 사본, `-k "opencode and not real_opencode"`, 3개 모듈)
| 뮤테이션 | 결과 |
|---|---|
| discover 를 `session` 테이블로 | 5 실패 |
| discover 의 `parent_id IS NULL` 제거 | 1 실패(`test_discover_and_verify…skip_child_sessions`) |
| verify_artifact 를 `session` 테이블로 | 6 실패 |
| purge 에서 CLI 호출 제거 | 4 실패 |
| `--` 제거 / `--standalone` 제거 | 각 1 실패 |
| `_resolve_db` 를 `db path` 로 | 2 실패 |
| residual 에서 `session_v2.id` 제거 | 3 실패 |
| reconcile `session` 테이블로 / status `session` 으로 | 각 1 실패(구조 검사) |
| create_session.sh·spawn_spec 을 `--agent build` 로 | 각 1 실패 |
| **reconcile drift C 의 `parent_id IS NULL` 제거** | **0 실패** (N2) |
## 4. 비차단 지적
**N1. opt-in 실바이너리 테스트가 opencode 가 PATH 에 없으면 거짓 실패한다** (`tests/test_issue60_opencode_v2.py:182-228`).
`_real_opencode()` 는 import 시점(실제 HOME)에 `~/.opencode/bin/opencode` 폴백으로 바이너리를 찾아 테스트를 켠다. 그런데 테스트 안에서 `HOME` 을 임시 디렉터리로 바꾼 뒤 어댑터의 `_bin()` 은 `shutil.which` → `ctx.home_dir/.opencode/bin`(임시 home) → `expanduser("~")`(역시 임시 home) 순이라 바이너리를 못 찾는다. 결과:
```
env -i PATH=/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin ... pytest tests/test_issue60_opencode_v2.py
FAILED test_real_opencode_2x_contract - assert [] == ['opencode session (with its child sessions): ses_…']
WARN: purge opencode: no opencode binary to delete session ses_…
1 failed, 9 passed
```
PATH 에 `~/.opencode/bin` 을 넣으면 10 passed. 공식 설치기의 기본 위치가 `~/.opencode/bin` 이고 PATH 등록은 셸 rc 에 의존하므로, Linux 호환성 시험(CI·비로그인 셸)에서 제품 문제로 오인될 수 있다. 같은 이유로 `_resolve_db` 단언(`:218`)은 폴백 경로와 같아서 `debug paths` 파싱을 검증하지 못한 채 통과한다. 수정 예: 테스트에서 `monkeypatch.setattr(adapter, "_bin", lambda ctx: REAL)` 또는 `monkeypatch.setenv("PATH", os.path.dirname(REAL) + os.pathsep + os.environ["PATH"])`. 브리프가 정한 실행 방식(`env -u …`, PATH 상속)에서는 통과하므로 차단으로 보지 않았다.
**N2. reconcile drift C 의 `parent_id` 필터는 행동 테스트가 없다.** `test_status_and_reconcile_read_session_v2` 는 문자열 검사뿐이고, tier2 e2e(`test_comp_opencode_create_and_reconcile_and_stop`)는 자식 세션을 시드하지 않는다. 위 표처럼 필터를 지워도 아무 테스트도 실패하지 않는다. 어댑터 discover 쪽은 잡힌다.
**N3. (기존 동작, 이번 PR 이 바꾸지 않음) opencode 의 in-use 판정은 공유 DB 를 lsof 하므로, 어떤 opencode 프로세스든 DB 를 열고 있으면 항상 "사용 중"이다.** `base.py:307-309` 가 `artifact_path`(=모든 세션이 공유하는 `opencode.db`)를 lsof 한다. 이 Mac 에서는 사용자 서비스(pid 86524 `opencode serve --service`)가 실제 DB 를 열고 있다(`lsof -t ~/.local/share/opencode/opencode.db` → 86524). 임시 HOME 재현: DB 를 아무 프로세스가 열지 않으면 `in-use opencode <id>` → `in_use: false`, rc 0, 다른 python 이 같은 DB 를 열어 두면 → `in_use: true`, rc 4. 따라서 이 Mac 에서는 `stop --purge-conversation` 이 opencode 세션에 대해 항상 exit 4 로 거부되어, 이번에 새로 만든 `session delete` 경로는 stop 을 통해서는 닿지 않는다(안전한 쪽의 오탐). 힌트 문구도 명령줄만 언급하고 DB 를 연 프로세스는 말하지 않는다. base 의 `_resolve_db` 도 같은 DB 로 귀결되므로 회귀는 아니다. 세션 단위 판정(예: `--session <id>` argv 와 그 세션의 TUI pid)으로 바꿀지는 별도 이슈로 다룰 일이다.
**N4. (기존 동작) XDG_DATA_HOME 덮어쓰기.** `_run` 은 사용자의 `XDG_DATA_HOME` 을 무시하고 `~/.local/share` 로 강제하며, status.sh·reconcile 폴백도 `~/.local/share/opencode` 고정이다. Linux 에서 `XDG_DATA_HOME` 을 따로 둔 사용자라면 pane 의 TUI 는 다른 DB 에 쓴다. 기존 코드와 같은 동작이며 기본 설정에서는 문제 없다. Linux 시험 때 확인할 항목으로 남긴다.
**N5. (사소) `_resolve_db` 가 호출마다 opencode 를 실행한다.** discover 는 후보마다 `verify_artifact` → `_resolve_db` 를 다시 부른다(최대 21회 × 약 0.04 s). 기존 `db path` 호출과 같은 구조라 회귀는 아니다.
## 5. Linux 이식성
- 경로: `debug paths` 가 HOME·XDG 를 따르고, 어댑터는 그 출력을 쓴다. `/proc`·macOS 전용 명령은 새로 쓰지 않았다. in-use 는 기존 `ps -eo` + (있으면) `lsof`.
- 테스트: 가짜 CLI 는 `#!/usr/bin/env python3`, 래퍼는 `#!/bin/sh` + `exec` — Linux 에서도 동작. 실바이너리 테스트는 `os.path.realpath(ws)` 로 디렉터리를 맞춘다(macOS `/private` 접두사도 처리). N1 만 Linux 에서 문제가 될 수 있다.
[VERDICT: PASS]
@@ -0,0 +1,84 @@
# Issue #40 구현 계획 정교화 — agy 동일 cwd 오등록 방지
## 반영한 이의제기와 결론
Creator의 이의는 타당합니다. `last_conversations.json`은 cwd를 키로 UUID 하나만 저장하므로 그 값을 후보로 삼는 것만으로는 agy 후보가 최대 하나가 됩니다. 따라서 일반적인 `candidate_count > 1` 검사는 이 경우 작동하지 않습니다. 이 캐시는 대화를 연 시점에 갱신되며 현재 대화의 소유자를 증명하지도 않습니다. 특히 `agy.verify_artifact()`의 형제 UUID 검사가 `cache_match`가 아닐 때만 실행된다면, cache hit만 사용하는 자동 pin 경로에서 형제 배제는 무력화됩니다. 같은 cwd의 동시 세션은 실제 지원 시나리오이므로 epoch와 viewport 확인도 대화 소유권을 대신할 수 없습니다.
이에 따라 agy는 **캐시 일치만으로 자동 pin하지 않습니다.** 캐시는 검사할 UUID를 제안하는 데만 쓰고, 해당 UUID의 열린 presence lock을 보유한 프로세스가 현재 행의 pane 프로세스이거나 그 자손인지 확인해야 합니다. 확인할 수 없거나 소유자가 불일치하면 fail closed로 미확정 처리합니다. 같은 cwd에 다른 non-stopped agy 세션이 있으면 이 소유권 증명이 필수이며, 증명 없이 pin하지 않습니다. 형제 UUID 배제는 cache hit 여부와 무관하게 항상 적용합니다.
## 현재 기준선과 범위
대상 워크트리 `/Users/godopu16/PuKi/laa/canary_projects/mam-wt-issue40`, 브랜치 `fix/issue-40-auto-pin`은 base/head 모두 `ff8b381520ae04e80c73a9e18bfad73d47e853c9`이며 변경 사항이 없습니다. 본 작업은 계획서 갱신이며 코드·브랜치를 수정하지 않습니다. 구현은 이 워크트리에서 진행합니다.
목표는 Class A 어댑터의 첫 로컬 `completed` 작업을 통해 미등록 세션 ID를 안전하게 확정하는 것입니다. 어댑터는 수명주기와 읽기 전용 capture 사실을 제공하고, completed를 검증한 로컬 수신 측만 YAML/SQLite를 갱신합니다. `publish_event.py`는 계속 이벤트 발행만 맡습니다. wrapper subscriber 종결 처리와 `loop.runner.wait_for_job()` 복구 경로는 같은 handler를 호출하고 `(job_id, dispatch_token, attempt_id)`로 중복을 제거합니다. remote/human job 및 pre-assigned ID는 제외합니다.
## 정교화한 설계
1. **Capture 결과와 lifecycle 계약**
`BaseAgentAdapter`에 `id_lifecycle` 및 `capture_active_session_id(ctx)` 계약을 추가합니다. 결과는 ID와 구체적 상태/사유를 포함해 `captured`, `no-candidate`, `ambiguous(shared-cwd)`, `unverifiable-owner`, `owner-mismatch`, `sibling-claimed`, `stale`, `wrong-workspace` 등을 구분합니다. Claude/Grok은 `pre_assigned`로 선언하고 자동 capture하지 않습니다. agy·opencode·codex·hermes는 `post_discovered` 대상입니다. 추가/기존 어댑터가 결과를 명확히 전달하도록 하며, `None` 하나로 모든 실패 원인을 뭉개지 않습니다.
2. **공용 handler와 저장 경계**
`lib_py/agents/autopin.py`와 같은 공용 경로에 판정·pin handler를 둡니다. 검증된 local `completed` job, 세션 row, transport, agent, dispatch token 및 attempt를 확인하고, 기존 ID/lifecycle을 존중한 뒤 adapter capture, workspace/epoch 검증, sibling 배제, viewport 확인을 수행합니다. 판정 코드는 저장소를 직접 우회하지 않고 기존 `atomic_dump_yaml` 경로에서 row와 관련 SQLite 상태를 갱신합니다. 성공 때 own ID, `session_id_source='discovered'`, `session_id_verified=True`, 표시 상태를 일관되게 기록하고, 실패 시 기존 own ID를 변경하지 않은 채 구체적 사유를 보존합니다. capture 실패는 이미 성공한 작업의 terminal 상태를 실패로 뒤집지 않습니다.
3. **agy는 캐시 후보와 실행 중 소유권을 모두 증명**
- `last_conversations.json[normalized_cwd]`는 후보 UUID 검색의 시작점일 뿐, 단독 pin 근거로 사용하지 않습니다. 해당 map이 다른 cwd를 가리키는 UUID는 즉시 거절합니다.
- 후보 UUID의 `presence/<uuid>.lock` (필요하면 기존 artifact 경로 계약에 따른 DB/sidecar)을 열고 있는 PID를 `lsof`로 조회합니다. 소유 PID는 해당 행의 `pane.pid` 또는 그 자손 프로세스여야 합니다. PID/조상 관계를 확인하는 동안 프로세스 종료·PID 재사용 가능성을 고려해 실제 프로세스 존재와 계보 확인에 실패하면 성공으로 추정하지 않습니다.
- lsof 부재/오류, lock 미점유, pane PID 누락, 계보 불일치 등의 경우 pin하지 않고 각각 재현 가능한 fail-closed 사유를 기록합니다. 실제 CLI 실행 상태를 확인할 수 없는 환경에서 cache-only fallback은 두지 않습니다.
- 동일 normalized cwd를 가진 다른 non-stopped agy row가 있으면 owner 검사가 통과해야만 그 소유 행에 pin할 수 있습니다. 서로 다른 행이 같은 캐시 UUID를 갖는 동시 처리에서도 pane 소유권을 통과한 행만 후보가 됩니다. owner가 불확실하면 둘 다 미확정으로 남을 수 있으며 이는 안전한 결과입니다.
- `verify_artifact()`의 sibling claim 검사는 cache match와 무관하게 먼저 적용합니다. 다른 활성/등록 행이 이미 보유한 UUID를 거절합니다. own row의 재검증은 자기 claim을 sibling으로 오인하지 않도록 제외합니다.
- reconcile drift C와 completed capture는 같은 agy 판정기를 호출합니다. 대화 폴더 `*.db` 전체를 훑어 얻은 단독 후보를 자동 pin하지 않습니다. 다른 workspace의 DB를 읽지 않도록 막고, DB 조회는 `base._ro_connect`를 사용합니다.
4. **나머지 Class A 어댑터**
OpenCode는 세션 DB의 directory/time, Codex는 state DB의 cwd와 rollout metadata, Hermes는 sessions의 cwd/started_at을 사용합니다. 각 후보는 workspace와 세션 epoch(`herdr_session_epoch`) 조건을 모두 만족해야 하며 검증된 후보가 정확히 하나일 때만 확정합니다. 다수 후보는 `ambiguous`로 남깁니다. agy의 presence owner 규칙은 다른 어댑터에 억지로 일반화하지 않습니다.
5. **동시 completed 처리와 재수신**
job/token/attempt 키 아래 직렬화하고 처리 결과를 남깁니다. wrapper와 loop 복구 경로가 연속 또는 동시 호출되어도 하나의 capture만 수행합니다. handler가 상태 저장 전에 중단된 경우 재시도는 기존 own ID와 artifact를 재검증해 멱등이어야 합니다. agy의 owner 판별은 각 처리 시점에 수행하며, 캐시가 바뀌어도 UUID 소유 pane을 잘못 선택하지 않습니다.
6. **호출 지점**
subscriber가 검증한 completed, subscriber timeout 뒤 disk fallback에서 확정한 completed, loop `wait_for_job()`의 completed 반환 직전 모두 동일 handler로 이어집니다. 기존 wrapper가 순수 상태 매핑을 맡고 있다면 얇은 Python 호출 경계를 추가합니다. subscriber와 `publish_event.py`에 registry/YAML/SQLite 쓰기를 추가하지 않습니다.
## 단계별 실행 및 검증 기준
### 1단계 — 실패 재현과 회귀 테스트
- Creator의 같은 cwd 재현을 테스트 fixture로 옮깁니다. A와 B가 동일 cwd이고 cache는 UUID U 하나를 가리키며, U의 presence lock 소유자는 A의 pane PID인 상태에서 B는 discover/capture/verify 모두 U를 얻지 못해야 합니다.
- 동일 cwd 동시 onboard 완료 두 건을 모의합니다. cache UUID는 하나여도 결과는 소유 pane 행만 pin할 수 있고, PID 확인이 불가능하거나 불일치하면 어느 행도 pin하지 않습니다.
- `test_agy_capture_requires_presence_owned_by_pane_pid`: lsof 결과를 모의해 일치 PID/자손, 다른 PID, lock 미점유, lsof 오류를 검증합니다.
- `test_agy_capture_rejects_cache_uuid_owned_by_sibling_in_same_cwd` 및 `test_agy_concurrent_onboard_same_cwd_no_cross_pin`을 추가합니다. sibling 배제 검사는 cache-hit에서도 실행되어야 합니다.
- Issue #64의 `verify_session_uuid(...uuid_b...) is True` 기대를 `False`로 바꾸고, 외부 DB에 sidecar가 생기지 않는 확인은 유지합니다.
### 2단계 — adapter 및 agy 판정
- lifecycle 선언, capture 결과 타입, epoch/workspace 단일 후보 규칙을 테스트합니다.
- A/B 캐시 전환, 타 workspace mapping, 같은 cwd sibling claim, cache match여도 sibling UUID 거부, presence 소유자가 pane 또는 자손인지 확인합니다.
- lsof를 쓸 수 없는 상황에서는 `unverifiable-owner` 등 사유와 함께 미확정임을 확인합니다. 어떤 예외 경로도 cache-only 자동 pin으로 되돌아가면 안 됩니다.
### 3단계 — 공용 completed handler
- daemon 없는 onboard 완료, OpenCode/Codex/Hermes의 첫 completed pin, 기존 ID/pre-assigned skip, remote/human/stale token/non-completed 제외를 확인합니다.
- wrapper completed와 loop 복구 완료를 같은 키로 호출해 capture 및 저장이 한 번뿐인지 검증합니다.
- 모호성·검증 실패의 사유가 보존되고 자체 ID는 그대로인지 확인합니다.
### 4단계 — reconcile 통합 및 회귀
- drift C가 공용 판정기를 사용하고 agy의 전체 DB glob 후보를 자동 pin에 쓰지 않는지 테스트합니다.
- same-cwd A/B에서 B의 최신 cache 값과 A의 더 최근 epoch가 있어도 lock 소유 pane이 A라면 A만 pin될 수 있고, B는 pin되지 않는지 검증합니다.
- subscriber, retained/disk fallback, daemon 없는 실행, loop 복구 경로에서 동일 dedupe key를 확인합니다. publisher가 제어-plane 상태를 쓰지 않는 것도 확인합니다.
### 5단계 — 검증과 범위 마무리
- 지정 Python 환경에서 신규 Issue #40 테스트, 갱신된 Issue #64, UUID/tier2/dispatch 및 관련 adapter/reconcile 테스트를 실행합니다. 전체 테스트는 상위 오케스트레이션에 맡깁니다.
- 테스트는 임시 HOME/DB와 source 바깥 basetemp를 쓰고 실제 agent home, `.mam.env`, monitor/herdr 서비스에 접근하지 않습니다. 변경한 shell은 `bash -n`, diff는 `git diff --check`로 확인합니다.
- 작업 전후 branch/status를 확인하고 요청 범위 파일만 stage합니다. 버전 파일 및 `.venv`는 추가하지 않습니다.
## 위험과 명시적 한계
가장 큰 위험은 잘못 pin된 ID가 잘못된 resume 또는 purge로 이어지는 것입니다. 따라서 agy에서 lock 소유 프로세스와 pane의 관계를 확인할 수 없으면 자동 pin을 포기합니다. 이는 일부 환경에서 정상 대화도 자동 등록되지 않는 false negative를 허용하는 fail-closed 선택입니다. 추후 에이전트가 presence lock을 열지 않는 지원 모드가 확인되면 별도의 식별 가능한 프로세스 귀속 신호를 설계해야 하며, 기존 cwd 캐시를 대체 증거로 승격하지 않습니다.
같은 cwd의 활성 agy 세션 수가 많을수록 cache-only 판정은 신뢰할 수 없습니다. viewport 검사, epoch, 최신 mtime은 owner 증명이 아닙니다. stop의 기존 capture와 source/verified 메타데이터 정합성은 별도 후속 범위로 남기되, completed pin이 이를 덮어써 기존 ID를 변경하지 않도록 회귀 테스트합니다.
@@ -0,0 +1,14 @@
{
"loop_id": "loop-20261008T133858Z-9e7f5b",
"base_sha": "6b00b6e06f55299cb60f156ccef09ede4bae5bdd",
"head_sha": "ac48cb78617af88584db529b5766acf02f4af73c",
"tree_sha": "b043034eda891bd83b547dcb34d7422a6977b89d",
"review_commit": "ac48cb78617af88584db529b5766acf02f4af73c",
"reviewer": "planner-reviewer-codex-01",
"job_id": "05537aa1",
"verdict": "PASS",
"transport": "herdr",
"evidence": "review-tree",
"isolation": "worktree",
"verified_at": "2026-10-08T18:40:20Z"
}
@@ -0,0 +1,121 @@
# Review — 05537aa1
## 판정
PASS. 이전 라운드에서 빠진 O1/O2의 run_loop 실행 경로에 격리가 추가되었으며, 새로운 차단 결함은 확인하지 못했다. 브리프가 허용한 정적 검토와 제한 환경 실행으로 판정했다. 전체 통합 스위트 통과를 주장하지 않는다.
## Identity 및 변경 범위
- loop: `loop-20261008T133858Z-9e7f5b`
- base: `6b00b6e06f55299cb60f156ccef09ede4bae5bdd`
- head: `ac48cb78617af88584db529b5766acf02f4af73c`
- tree: `b043034eda891bd83b547dcb34d7422a6977b89d`
- 검증 cwd: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261008T133858Z-9e7f5b/3`
- Python: `/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux-wt/issue17-wsroot/.venv/bin/python` (3.9).
위 identity를 확인했다. 검증 전후 git status가 비어 있고 `git diff --check 6b00b6e HEAD` 및 변경된 Python 17개 파일 AST 파싱이 통과했다. 제품 코드 변경은 없다. creator 작업 트리에서는 실행/수정하지 않았고 검증 소스도 수정하지 않았다.
이전 검토 head `8b47cc76eed4582b1848b2eec529523a61497320` 이후 diff는 다음 세 파일뿐이다:
1. `tests/test_o1_rebuttal.py:10` — 모듈에 own_workspace_root fixture opt-in 추가.
2. `tests/test_o2_race_free_lock.py:196` — run_loop를 실행하는 O2-12에만 fixture opt-in 추가.
3. `tests/test_issue17_workspace_root_isolation.py:41` — W1의 nested pytest 목록에 O1-V3와 O2-12 추가.
O2 모듈 전체에 fixture를 적용하지 않은 것은 적절하다. `test_o2_7_no_leftover_temp_files`는 tmp_path가 비어 있어야 하므로 새 workspace 디렉터리가 만들어지면 불필요한 실패가 된다. O2-7은 이번 두 실행에서 모두 통과했다. 나머지 O2 잠금 함수 테스트는 lib.sh가 아닌 loop_lock.sh를 source한다.
## 이전 보고서 정정 및 누적 검토
이전 c20f3d1f 보고서의 O2 제외 근거는 일부 상태 helper에만 해당했으며, `test_o2_12_run_loop_exits_on_lock_failure`의 직접 run_loop 실행까지 포괄하지 못했다. 그 제외 설명과 '추가 동일 결함 없음'이라는 결론은 범위가 너무 넓었다. 이번에는 O1-V3와 O2-12의 이전 코드가 실제 sentinel에 shim을 생성하는 것을 확인했고, 현재 추가된 fixture로 격리되는 것을 확인했다.
기존 누적 수정은 이전 head와 동일하다:
- `test_issue17_item4_decisions.py:224`: `_env`에서 상속 WORKSPACE_ROOT를 제외하고 tmp_path로 고정. unset만 하면 lib.sh가 checkout을 기본값으로 쓰므로 명시적 임시 root가 맞다.
- `conftest.py:180`: opt-in own_workspace_root. 기존 mam_sandbox와 테스트 본문의 명시적인 root 설정이 나중에 적용되는 구조를 유지한다.
- `test_orc_onboard.py:19,27`: import 시점 캐시에서 WORKSPACE_ROOT를 제거하고 호출 시점의 root를 적용한다.
- `test_workspace_scope.py:31`, `test_issue21_worktree_prereqs.py:544`: fixture opt-in 대신 해당 helper의 임시 root를 직접 지정한다.
- b19/b8/loop CLI/phase3/phase4/O3/version/issue25/fail_closed 모듈의 셸 실행은 own_workspace_root를 사용한다.
이전 head에 대한 상세 fixture 우선순위 검증과 135 passed × 2, 추가 84 passed, 별도 3 passed 결과는 다음 이전 보고서에 있다. 이를 현재 head에서 새로 실행한 결과로 세지 않았다:
`/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.mam/jobs/c20f3d1f/codex-reports/report-final.md`
## 이번 실행
환경에서 HERDR_*, MAM_*, MQTT_*, SKS_*, WORKSPACE_ROOT 및 control-plane 경로 변수를 제거하고 HOME을 임시 경로로 지정했다. sentinel은 미리 생성한 빈 디렉터리다. 모든 산출물과 basetemp는 검증 트리 밖에 두고 bytecode/cache 쓰기를 껐다. 실제 .mam.env를 export하거나 출력하지 않았다.
샌드박스는 MQTT 연결/로컬 broker socket bind 및 ps 실행을 제한한다. 따라서 `/tmp/05537aa1-run.py`는 현재 검증 트리의 실제 conftest를 import하여 local_mqtt_broker fixture만 메모리에서 제외하고, `--noconftest`, `-p no:cacheprovider`, 임시 basetemp와 `plugins=[module]`로 실행했다. 나머지 fixture/hook/상태 guard는 그대로 등록했다. 소스 파일 변경이나 권한 우회는 하지 않았다.
실행 대상:
- `tests/test_o1_rebuttal.py` 전체.
- `tests/test_o2_race_free_lock.py` 전체.
- `test_issue17_workspace_root_isolation.py::test_w2_onboard_does_not_carry_its_import_time_root`.
- `test_issue17_item4_decisions.py::test_c_helpers_write_nothing_to_an_inherited_workspace`.
| 조건 | 결과 | sentinel 상태 |
|---|---|---|
| WORKSPACE_ROOT unset | 24 passed, 10 failed, 6.54s | 0 entries |
| WORKSPACE_ROOT=sentinel | 24 passed, 10 failed, 6.56s | 0 entries |
O1의 10개 테스트와 두 기존 격리 회귀 테스트는 모두 통과했다. 실패한 O2 테스트는 두 조건에서 동일하게 O2-2/3/4/11/12/16/17/18/19/20이다. `loop_lock.sh:39` 부근에서 자신의 ps lstart를 얻지 못하면 holder state가 unknown이 되고 `:108` 부근에서 rc 4를 반환한다. 따라서 live/stale 판정을 기대하는 테스트가 실패한다. O2-12 로그도 명확히 `Cannot determine whether another loop is running (ps unavailable?)`를 출력한다. 이는 이번 WORKSPACE_ROOT 수정으로 잠금 동작이 바뀐 것이 아니다.
기능 assert가 ps 제한으로 실패한 O2-12도 lib.sh bootstrap은 실행된다. 실행 후 O1-V3와 O2-12 각각의 `.mam-test-state/workspace/.mam/shim/herdr`가 존재하고 외부 sentinel이 비어 있음을 두 조건에서 직접 확인했다. 검증 checkout의 `.mam` 아래에도 항목이 없다.
로그/드라이버:
- `/tmp/05537aa1-run.py`
- `/tmp/05537aa1-unset.log`
- `/tmp/05537aa1-sentinel.log`
- unset root: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/05537aa1-unset-ecvw8t06`
- sentinel root는 `/tmp/05537aa1-sentinel.log` 첫 ROOT 행에 기록되어 있다.
## 수정 전 실패 확인
`/tmp/05537aa1-negative.py`가 `git show`로 읽은 이전 코드를 메모리에 로드했다. __file__은 현재 검증 트리 경로로 지정하고, 실행되는 제품 코드는 현재 검증 트리의 변경 없는 코드다. 검증 트리를 checkout/reset하거나 수정하지 않았다.
| 시나리오 | 수정 전 실제 결과 | 현재 실제 결과 |
|---|---|---|
| base `_env`를 현재 decisions 회귀 함수에 적용 | `AssertionError: written into the sentinel` | 동일 회귀 함수 통과 |
| onboard를 sentinel root에서 import한 후 fixture root로 변경하고 run_find_uuid 호출 | base: sentinel 3 entries, own shim 없음 | head: sentinel 0 entries, own shim 있음 |
| 이전 head의 O1-V3 함수 직접 실행 | 함수 자체는 성공하지만 sentinel에 `.mam/shim/herdr` 생성 | pytest fixture 적용 시 own root에만 shim 생성 |
| 이전 head의 O2-12 함수 직접 실행 | ps assert 실패 전 sentinel에 `.mam/shim/herdr` 생성 | 같은 ps 제약하에서도 own root에만 shim 생성 |
기대 결과는 모두 외부 sentinel 불변이다. 새 opt-in이 없던 O1/O2는 이 기대를 위반하고, 현재 pytest 실행에서는 만족한다. O1/O2 negative control은 함수를 직접 호출한 탐침이며 W1 전체 nested pytest의 성공/실패 결과라고 표현하지 않는다.
negative log: `/tmp/05537aa1-negative.log`
negative root: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/05537aa1-negative-_c2j_av4`
W1은 actual conftest/broker를 사용하는 nested pytest이므로 이 샌드박스에서 완주하지 않았다. 새 두 node가 실제 수집 가능한 함수명과 일치하며, subprocess 성공 및 통과 수 검사 뒤 copied checkout의 `.mam` 비어 있음을 검사하는 구조를 정적으로 확인했다.
## 같은 결함 후보 재조사
환경 복사만 검색하지 않고 `run_loop.sh`, `lib.sh`, WRAPPER, source, subprocess 실행도 교차 검색했다. 아래는 이번에 재확인한 미수정 후보의 제외 이유다. 이전 보고서의 O2 포괄 제외는 이 표로 대체한다.
| 파일:라인 | 미수정 이유 |
|---|---|
| tests/test_o2_race_free_lock.py:14,23 | 일반 lock helper는 loop_lock.sh만 실행. 직접 run_loop를 실행하는 :199는 이번에 수정됨. |
| tests/test_herdr_shim_contract.py:10,139 | lib.sh를 실행하는 계약 테스트/공유 helper 호출은 mam_sandbox/mock fixture를 사용한다. |
| tests/test_sanitize_and_mock_errors.py:28 | bash parity test가 mam_sandbox를 사용한다. |
| tests/test_issue7_workspace_packing.py:49 | 셸 helper 호출이 mam_sandbox 기반이다. |
| tests/test_issue21_acceptance.py:280 | run_loop 실행 환경에 임시 main root를 명시한다. |
| tests/test_issue26_envfile.py:73 | `_env(home, ...)`가 HOME과 WORKSPACE_ROOT를 home으로 설정한다. |
| tests/test_issue30_stall_watch.py:124,231 | box/mam_sandbox와 격리된 환경 helper를 사용한다. |
| tests/test_issue30_stall_watch_stop.py:29 | `_env(home)`이 WORKSPACE_ROOT=home을 지정한다. |
| tests/test_issue45_restore.py:93,172 | run_lib의 rig는 mam_sandbox에 의존한다. 실제 서버 테스트는 이번 리뷰에서 실행하지 않았다. |
| tests/test_issue24_resume_dialogs.py:70,82 | create/stop/resume 실행이 mam_sandbox/mock fixture를 사용한다. |
| tests/test_issue15_registry_isolation.py:75 | 별도 subprocess 환경에 임시 WORKSPACE_ROOT를 명시한다. |
| tests/test_issue5_phase0_contracts.py:481 | run_loop 언급은 정적 문서 계약 검사다. |
| tests/test_issue14_delivery_ack.py:33; test_issue18_liveness_contract.py:54 | 환경 helper에서 cwd를 root로 지정한다. |
| tests/test_issue34_job_tree.py:50; test_issue36_prompt_transport.py:47 | 환경 helper가 임시 home/root를 고정한다. |
| tests/test_c1_tui_readiness.py:12; test_layout.py:310 | 별도 임시 root를 source 전에 지정한다. |
| tests/test_tier1_unit.py:14; test_issue5_phase1_delegation.py:49 | 셸 helper 호출은 mam_sandbox를 사용한다. |
| tests/test_b7_diff_untracked.py:11 | diff_collect는 lib.sh의 shim bootstrap 경로가 아니다. |
추가 동일 결함은 확인하지 못했다. 이번 변경은 pytest fixture 사용 추가여서 Bash 제품 문법을 바꾸지 않는다. O1의 bash 3.2 empty-array 검증도 이번 실행에 포함되어 통과했다.
## 검증 한계 / Lifecycle
새 변경은 3개 파일이므로 그 변경과 회귀 탐침을 재검증했으며, 이전 head와 동일한 전체 파일 묶음은 반복하지 않았다. 변경 파일 전부와 issue33_budget/phase1_delegation/tier1 전체의 두 환경 통합 실행을 이번 리뷰가 모두 통과시켰다는 주장은 하지 않는다. 제한 없는 전체 실행은 브리프에 따라 오케스트레이터가 수행해야 한다. 환경 제한 자체를 NOT PASS 사유로 삼지 않았다.
started 명령을 먼저 실행했으나 MQTT 연결이 Operation not permitted로 3회 실패하여 exit 2였다. 보고서 작성 후 요청된 completed 명령을 실행한다.
[VERDICT: PASS]
@@ -0,0 +1,128 @@
# PR #59 Mac review — NOT PASS
Job `05572944`, role Reviewer. 소스 수정 없이 검토했다. 선택 테스트 총 **80 passed, 1 skipped**이지만, 추가 탐침에서 삭제 범위 문제 두 건과 live-process 판정 문제 한 건을 확인했다. 병합 전 수정이 필요하다. 국소적인 안전성 수정 및 회귀 테스트로 해결 가능하므로 재계획 에스컬레이션은 하지 않는다.
## Review identity
- loop: `loop-20261016T003524Z-a51548`
- base: `26e2c43753169671b600d03274901dbe203988dd`
- head: `6f4f40a4adb2115d5997dffd31fb6473f05cab68`
- tree: `f4d9422ad0c1d616b5f37065f3e96b34c28f78d8`
- 검증 트리: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261016T003524Z-a51548/1`
- 범위: base..head 13 commits / 12 files. 아래 파일:라인은 이 head 기준이다.
- PR #59 본문과 issue #31을 tea의 읽기 명령으로 확인했다.
## R1 — [P1] 상위 디렉터리의 심볼릭 링크를 따라 소유 루트 밖을 삭제한다
근거:
- `.agents/skills/lib_py/agents/adapters/claude.py:279`–`:284`: tmp root의 모든 entry를 key로 간주하고 `root/key/uuid`를 후보에 넣는다. key가 외부 디렉터리를 가리키는 링크인지 검사하지 않는다.
- 같은 파일 `:80`–`:85`: `_remove`는 최종 path 자체가 링크인지 확인하지만, 상위 key가 링크이면 최종 uuid 디렉터리는 일반 디렉터리로 보인다. 따라서 `shutil.rmtree`가 링크 너머 실제 디렉터리를 삭제한다.
- 같은 파일 `:266`–`:270`의 projects/file-history/session-env 경로에도 상위 구성요소 경계 검증은 없다.
재현(실제 홈/스크래치패드는 사용하지 않음):
1. 임시 config/HOME/workspace/tmp를 만들고 새 UUID U를 생성한다.
2. 임시 소유 루트 밖인 `<review-temp>/outside-owned-roots/U/keep.txt`를 만든다. 이는 테스트용 sentinel이며 실제 사용자 파일이 아니다.
3. 임시 `<ctmp>/claude-<uid>/linked-workspace`를 `outside-owned-roots`에 연결한다.
4. `_tmp_roots`를 이 임시 claude root 하나로 제한하고 `ClaudeAgentAdapter.purge_artifacts(U, ctx)`를 실행한다. 제한은 실제 `/tmp/claude-<uid>`에 접근하지 않도록 하는 시험 장치이며 경로 열거/삭제 로직은 변경하지 않는다.
기대: 소유 경계 밖 sentinel은 보존되어야 한다. 최소한 링크를 경유한 후보를 거부하고 residual/경고로 남겨야 한다.
실제:
```text
ANCESTOR_SYMLINK {"victim_exists": false, "link_survives": true,
"purged": [".../ctmp/claude-501/linked-workspace/<U>"]}
```
상위 링크 자체는 남고 링크 너머 디렉터리가 삭제됐다. 최종 UUID entry 자체를 symlink로 만든 대조군에서는 target이 살아 있고 링크만 삭제됐다. 즉 leaf symlink 보호만으로 해결되지 않는 문제다.
수정 방향: 명시적으로 설정한 root를 신뢰 경계로 정규화한 뒤, 그 아래 탐색 중 만난 symlink 디렉터리를 따라가지 않는다. tmp의 workspace key 등 중간 경로도 `lstat`/no-follow로 확인해야 한다. 경계 밖 realpath 후보는 삭제하지 말고 보고한다. 링크 교체 경합까지 고려하면 디렉터리 descriptor와 no-follow 연산으로 안전하게 경로를 여는 방식이 적절하다. leaf-link 대조군과 ancestor-link 탈출 회귀 테스트를 둘 다 추가한다.
## R2 — [P2] UUID가 아닌 `memory`를 세션으로 받아 공유 메모리 디렉터리를 삭제한다
근거:
- `.agents/skills/lib_py/agents/base.py:89`–`:92`: `_plausible_id`는 일부 경로 문자만 배제한다. `memory`, 공백, 임의 문자열은 UUID가 아니어도 통과한다.
- `.agents/skills/lib_py/agents/adapters/claude.py:262`–`:268`: 이 검증만으로 `projects/<key>/<입력>` 디렉터리 전체를 소유 경로로 간주한다.
- 같은 파일 `:287` 및 `:296`: `has_artifacts`와 purge가 같은 오판을 공유한다. 기존 코드의 단일 `<uuid>.jsonl` 삭제와 달리 이제 디렉터리 전체 삭제로 이어진다.
재현:
1. 임시 config의 `projects/<workspace_key>/memory/MEMORY.md`에 공유 메모리 sentinel을 만든다.
2. `adapter.has_artifacts("memory", ctx)`를 호출한다.
3. `adapter.purge_artifacts("memory", ctx)`를 호출한다.
기대: Claude 세션 UUID가 아닌 입력을 거부하고 memory를 보존해야 한다.
실제:
```text
MEMORY_ACCEPTED True
NON_UUID {"memory_exists": false,
"purged": [".../home/.claude/projects/<workspace_key>/memory"]}
```
현재 malformed-id 테스트는 빈 값, 점, 별표, slash만 다루므로 이 경우를 검출하지 않는다. 정상 UUID를 purge할 때 memory를 보존하는 테스트가 통과하는 것과는 별개다.
한계/구분: 이 호스트에서 실행한 **별도의 무수정 CLI** `purge claude memory` 탐침은 일반 `conversation_in_use`의 프로세스 문자열 검사에 걸려 rc 4로 끝났고 memory를 보존했다. 따라서 CLI가 이 실행에서 memory를 삭제했다고 주장하지 않는다. 위 삭제는 어댑터 호출에서 직접 재현했다. 프로세스 목록에 우연히 `memory`가 등장하는지 여부는 UUID 검증이나 공유 파일 소유권 보장의 대안이 아니며, CLI target 탐색도 이 잘못된 `has_artifacts`에 의존한다.
수정 방향: Claude의 디렉터리 삭제 후보를 만들기 전에 Claude 세션 식별자 형식을 검증한다(예: UUID 파싱 및 허용된 표기 검증). 다른 어댑터의 `ses_...` 식별자까지 전역적으로 금지하지 말고 Claude에 맞는 검증을 적용한다. `memory`, 공백, 임의 비-UUID 문자열은 has/purge에서 거부하고 실제 공유 memory 파일이 살아 있음을 회귀 테스트로 고정한다.
## R3 — [P2] NaN startedAt을 판정 불가로 처리하지 않아 live guard가 열린다
근거:
- `.agents/skills/lib_py/agents/adapters/claude.py:70`: `float(data["startedAt"])`가 `"NaN"`과 JSON NaN을 예외 없이 변환한다.
- 같은 파일 `:73`, `:77`: NaN은 `<= 0`도 거짓이고 `lstart <= started + 2`도 거짓이므로, 살아 있는 PID에 대해 False를 반환한다.
- 같은 파일 `:275`: 그 세션 파일은 죽은 프로세스의 소유 경로로 선택된다.
- `.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh:292`–`:298`: in-use rc 0이면 purge gate를 통과한다.
재현:
1. 실제 Claude 대신 `/bin/sleep 60`을 띄운다.
2. 임시 config의 `sessions/<sleep-pid>.json`에 새 UUID, 해당 live pid, `"startedAt": "NaN"`을 기록한다.
3. 모든 Claude/home/tmp 환경을 임시 위치로 지정한 CLI `python -m lib_py.agents in-use claude <UUID> <temp-workspace>`를 실행한다.
기대: 시작 시각을 비교할 수 없으므로 보수적으로 live=True, CLI rc 4.
실제:
```text
CLI_NAN {"rc": 0, "stdout": "{\"agent\": \"claude\", \"in_use\": false}\n",
"holder_alive": true, "session_file_exists": true}
```
직접 함수 탐침에서도 string NaN과 JSON NaN은 둘 다 False였고, `_owned_paths`에는 이 live PID의 세션 파일이 포함됐다. live fixture를 실제로 purge하지는 않았다.
수정 방향: startedAt 변환 후 `math.isfinite` 및 유효 범위를 확인하고, 비유한 값이면 True(live/unknown)를 반환한다. 필요하면 bool 등 시각이 아닌 타입도 배제한다. NaN/무한대/결손/파싱 불가 입력과 정상 live/dead/reused PID를 함께 검사한다.
## Mac 검증 및 통과한 범위
- `tests/test_issue31_purge_residual.py`: **32 passed in 23.89s**, rc 0.
- `tests/test_issue42_skill_blocks.py`, `tests/test_issue55_agent_homes.py`: **48 passed, 1 skipped in 1.48s**, rc 0. skip은 리뷰 worktree에 untracked `.claude/skills`가 없는 경우이다.
- base `26e2c43`의 임시 archive 복사본에 새 테스트 파일을 올려 핵심 3건을 실행: `test_claude_purge_removes_every_owned_path`, `test_claude_residual_lists_leftovers_then_nothing`, `test_purge_cli_refuses_a_live_claude_without_uuid_on_argv` 모두 예상대로 실패. 현재 head에서는 통과한다. 전체 32건의 base 비교를 다시 수행한 것은 아니다.
- 실제 `/usr/bin/python3` 3.9.6에서 변경 Python 8개 AST 파싱 통과. 변경 shell 2개를 각각 `/bin/bash -n`으로 검사하여 통과. 실제 Bash 3.2로 stop/in-use fixture 테스트 실행.
- 실제 BSD `/bin/ps -p <sleep-pid> -o lstart=` + LC_ALL=C 출력 예: `Fri Oct 16 10:18:16 2026`. 정상 현재 startedAt은 live=True, 한 시간 이전 startedAt은 PID 재사용으로 판정되어 live=False, 결손은 unknown/live=True. 파싱 불가 ps 출력의 보수적 처리는 신규 테스트로 통과했다. NaN은 위 별도 결함이다.
- 정상 UUID의 다른 세션/동일 jobs 접두사의 다른 세션/shared history 보존, stale session/key 삭제, 부분 삭제 실패의 소유 증거 유지, CLAUDE_CONFIG_DIR, stop rc 4 및 행 보존/삭제, codex/grok/opencode residual은 새 테스트 범위에서 통과했다.
- stop SKILL.md의 row 제거 설명과 `skill_code_blocks.json` 변경은 코드/문서 검사와 일치했다.
- `git diff --check base HEAD` 통과. 검증 후 HEAD/tree 일치 및 clean git status 확인.
## 실제 구조 관찰과 격리 범위
실제 홈에서는 디렉터리 목록/파일 종류/존재 여부만 읽었다. JSON, key, transcript 내용은 열지 않았다. 관찰 당시 `~/.claude`에는 projects 16개 디렉터리, file-history 18개, session-env 21개, sessions의 JSON 5개/key 5개가 있었다. jobs의 표본 디렉터리 두 곳에 state.json이 존재했다. `/private/tmp/claude-501`에는 workspace 디렉터리 5개가 있었고 표본에서 `<key>/<session>/scratchpad` 구조를 확인했다. `/tmp`의 realpath는 `/private/tmp`, 이 Mac의 TMPDIR realpath는 `/private/var/folders/.../T`였다. 이 관찰은 디렉터리 형태를 확인한 것이며 실제 session JSON 필드 값을 검증했다는 뜻은 아니다.
브리프의 금지 범위를 지키기 위해 테스트는 다음과 같이 격리했다.
- 지정 `env -u` 목록(HERDR/WORKSPACE_ROOT/MAM/DELEGATE/CLAUDE_CONFIG_DIR)을 적용하고, HOME/HOME_DIR 및 Claude 프로젝트·tmp 경로를 임시 위치로 지정했다. config 기본값 테스트는 임시 HOME의 `.claude`를 사용하며 custom config 테스트도 임시 경로만 사용했다.
- 코드가 명시적 임시 환경과 무관하게 `/tmp/claude-<uid>`도 탐색하므로, 테스트용 sitecustomize에서 **그 실제 fallback root에 대한 isdir만 False로 처리**했다. 그 실제 root에 대한 remove/rmdir/rmtree/rename도 방어적으로 차단했다. 이 테스트 보조 파일은 임시 디렉터리에만 있고 제품 소스는 변경하지 않았다.
- 따라서 tmp root 단위 테스트는 실제 fallback을 제외한 격리 환경에서 통과한 것이다. 실제 fallback의 존재/레이아웃은 위 읽기 관찰로 별도 확인했다.
- 수동 purge 탐침의 `_tmp_roots`는 임시 root 하나로 제한했다. CLI 탐침도 동일한 real-fallback 차단을 적용했다. 삭제 sentinel은 전부 리뷰 임시 디렉터리 안의 합성 데이터다.
- 실제 Claude 프로세스, herdr 서버, monitor는 시작하지 않았다. 실제 홈/실제 scratchpad/실제 agent session에 purge 또는 in-use를 실행하지 않았다. 제어 평면 쓰기는 지시된 lifecycle 이벤트와 본 리포트뿐이다.
- 모든 pytest 실행에 지정 venv Python, `PYTHONDONTWRITEBYTECODE=1`, `-p no:cacheprovider`, 검증 트리 밖 basetemp를 사용했다. creator 트리에서 실행하지 않았다.
- 전체 스위트는 오케스트레이터의 별도 실행 범위이며 이번 리뷰에서 재실행하지 않았다. 선택 테스트에서는 #55 오탐/teardown 오류가 없었다.
산출물: `/tmp/review-05572944-u3lt5wk_/`의 `pytest.log`, `docs-home.log`, `before.log`, `probes.py`, `probes.log`, `cli-probe.py`, `cli-probe.log`, `guard/sitecustomize.py`. 임시 스크립트는 해당 검증 트리 cwd에서 실행하도록 작성됐으며 모든 파괴적 동작은 자체 임시 환경을 만든 후 수행한다.
[VERDICT: NOT PASS]
@@ -0,0 +1,14 @@
{
"loop_id": "loop-20260921T150212Z-97d845",
"base_sha": "6fc9194f896eb2e13e123b8ebe7a9089b629764d",
"head_sha": "1e826515cc2a07ea1f8da0ff1259e1ea4dde86fd",
"tree_sha": "4fd82ad1c2682d12c3f5114818f97291af28a032",
"review_commit": "1e826515cc2a07ea1f8da0ff1259e1ea4dde86fd",
"reviewer": "planner-reviewer-codex-01",
"job_id": "08808795",
"verdict": "PASS",
"transport": "herdr",
"evidence": "review-tree",
"isolation": "worktree",
"verified_at": "2026-09-21T15:25:44Z"
}
@@ -0,0 +1,32 @@
# PR #32 independent review
Reviewer: planner-reviewer-codex-01. Job: 08808795.
Reviewed the exact detached review-identity path specified by the brief:
`/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20260921T150212Z-97d845/1`, at `1e826515`, for the five commits after base `6fc9194`. No checkout, source file, session, monitor, or real registry state was modified.
## Findings
The explicit plan-reuse change is coherent and closes the implicit-load defect.
- With neither `--plan` nor `--plan-file`, Python self-planning logs that no plan is loaded and does not glob `.agents/reports/<planner>/plan-*.md`. The legacy shell runner does the same. `resolve_planner_session` may still identify a planner for other loop bookkeeping, but it no longer supplies a plan through that path.
- `--plan-file` is parsed by both wrappers. Python resolves relative paths against the control root, preserves absolute and `~` paths, requires a readable regular file with non-whitespace content, and rejects bad inputs before registering jobs. It is mutually exclusive with `--plan`; `--planner` still requires `--plan`; review-only accepts a valid file but warns and ignores it because no implementation prompt is produced. The legacy shell runner rejects the option explicitly when `MAM_LOOP_PY=0`, before the loop lock.
- `_plan_text()` is the shared check for explicit files, initial planner reports, discussion refinements, and escalation refinements. An empty initial planner report stops the run rather than silently falling back to self-planning. An empty refine report preserves the existing plan. Correction prompts therefore never claim to carry an empty plan.
- Checkpoint `current_plan` is not a plan-discovery source. It is only recorded for the current run; a later no-option run starts planless, and a named `--plan-file` is read explicitly. Promotion still writes the plan only for an actual `--plan` run.
- Relative plan paths with spaces, missing paths, directories, empty/whitespace files, unreadable files, `--plan` conflicts, review-only handling, and legacy-shell rejection are covered by P1–P11. The tests run in temporary control roots and use isolated reports; they do not write the real `.mam` or `.agents/reports`.
- The loop skill’s old implicit-reuse wording is removed. The CLI usage, workflow examples, option table, and explicit-reuse description agree. No `.claude/skills` deployment copy exists in this checkout, so there is no second copy to synchronize.
The known release prerequisite remains outside this PR: the orchestrator must perform the 4.7.1 lockstep version bump and release entry before merge.
## Verification
All tests used `PYTHONDONTWRITEBYTECODE=1`, the interpreter from the brief, pytest cache disabled, and a basetemp outside the review path.
- `tests/test_issue25_plan_reuse.py`: **17 passed** in 1.70 s.
- `tests/test_issue25_plan_reuse.py`, `tests/test_loop_cli.py`, `tests/test_issue5_phase3_loop.py`, and `tests/test_version_consistency.py`: **39 passed, 2 failed** in 49.25 s when run exactly from the identity path. Both failures occurred before their assertions because `run_loop.sh` could not find `.venv` in the detached snapshot and fell back to system Python, which lacked `paho` (`RuntimeError: Failed to eager load lib_py.loop.autocomplete: No module named 'paho'`). This is a snapshot/setup issue, not a change in the plan-reuse code.
- Re-running the two failed compatibility cases with `AGENT_PYTHON_BIN` pinned to the brief’s main interpreter: **2 passed** in 0.32 s. This reaches and verifies the expected session-registration checks.
- `git diff --check 6fc9194...HEAD`: passed.
The focused plan tests passed without modification, and the only broader failures disappeared when the required interpreter was explicitly supplied. I found no additional merge-blocking defect beyond the known version gate.
[VERDICT: PASS]
@@ -0,0 +1,103 @@
# PR #41 독립 리뷰 — 0f12973f
**NOT PASS.** 알림 전용 원칙과 두 종료 시계의 분리는 대체로 구현됐지만, 감시자의 래퍼 종료 처리와 완료 후 재주입 방지에 재현 가능한 결함이 있다. 아래 R1·R2를 수정한 뒤 재검토해야 한다. 전체 설계를 다시 짤 수준은 아니며, 설계 합의 의견이나 예정된 4.8.0 버전 변경을 판정 근거로 삼지 않았다.
## 검증 identity와 범위
- loop: `loop-20260926T025230Z-a30a6d`
- base: `8ea65bb23830b56c4530897270edaa6e556de392`
- head: `1673818bb5fed14ed6fee87884e70d39e81fe91f`
- tree: `00d5e03e2d521c87c3474c032f2af6f7a1af9e92`
- 검증 cwd: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20260926T025230Z-a30a6d/1`
- 30개 파일의 누적 변경을 검토했다. 검증 전후 HEAD/tree가 일치하고 `git status --porcelain`은 비어 있다. 소스 수정·커밋·실제 에이전트/herdr 세션 조작은 하지 않았다. 테스트·탐침의 레지스트리, HOME, 모의 herdr, 프로세스는 임시 디렉터리에 격리했다.
이하 파일:라인은 이 head 기준이며 `.agents/skills/` 아래 경로는 생략하지 않았다.
## 병합 차단 결함
### R1 · P2 — 래퍼가 SIGTERM으로 끝나면 stall watcher가 고아로 남는다
**근거:** `.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job:700`에서 감시자를 백그라운드로 실행하고 PID를 저장한다. 정리는 같은 파일 `:385`, `:560`의 정상 `wait` 반환 뒤에만 실행한다. `stop_stall_watch`는 `:708`부터 있지만 EXIT/TERM/INT 정리 trap에 연결돼 있지 않다. 기존 `:1088`의 EXIT trap은 agent bootstrap 전용이며 `:1102`, `:1108`, `:1114`에서 해제된다.
**재현:** 검증 트리의 실제 래퍼를 `MAM_DELEGATE_JOB_NO_MAIN=1`로 source한다. 임시 레지스트리에 UUID4에서 생성한 잡 ID와 `status=running`을 쓰고, PATH 맨 앞에 항상 `working` snapshot을 반환하는 모의 herdr를 둔다. 실제 `start_stall_watch herdr:isolated-probe codex`를 호출하고 래퍼가 해당 PID를 wait하도록 한 뒤 래퍼 PID에만 SIGTERM을 보낸다. 감시자는 실제 `python -m lib_py.stall_watch` 프로세스다.
**기대:** SIGTERM 같은 처리 가능한 래퍼 종료에서 감시자와 소유한 대기 프로세스를 정리하고 회수한다. 잡 상태는 감시자가 임의로 닫지 않는다.
**실제:** 래퍼 rc `-15` 뒤에도 `ps`가 감시자 `30374`, PPID `1`, 상태 `S`를 반환했다. 원래 잡이 열린 상태면 이 감시자는 계속 관측하고 나중에 알림·재주입할 수 있다. 정상 완료만 확인하는 `tests/test_issue30_stall_watch.py:503`의 S9로는 잡히지 않는다. 탐침 종료 후 탐침 전용 프로세스 그룹을 정리했고 해당 PID의 소멸을 확인했다.
**수정 방향:** 감시자 시작 전에 래퍼 소유 자식의 공통 EXIT/TERM/INT 정리를 설치하고 기존 bootstrap trap과 합성한다. kill과 wait는 bash가 맡되, 정상·오류·취소 경로 모두에서 실행되도록 한다. 감시자가 대기 중 생성한 herdr 자식도 유한 시간 안에 정리되는지 포함한 신호 종료 테스트를 추가한다. SIGKILL을 trap으로 처리하라는 요구는 아니다.
### R2 · P2 — herdr 대기 중 완료된 잡에도 idle 재주입을 실행한다
**근거:** `.agents/skills/lib_py/stall_watch.py:270`에서 잡 상태를 읽고, `:284`에서 최대 수십 초 대기한 뒤 `:310`에서 이전 상태 그대로 `_act`에 전달한다. `_act`는 에이전트 상태·자식·입력창만 새로 수집하며 잡 상태와 dispatch identity는 다시 확인하지 않는다. `:352`의 알림 뒤 `:354`는 무조건 `prompt`를 호출한다. `_notify`의 `:243` 부근은 발행 거부를 로그로만 남기므로 발행 CAS가 재주입을 막아주지도 않는다.
**재현:** 실제 `StallWatch.run()`에 모의 `wait_once`와 Source를 주입한다. 첫 대기에서 idle/seq=1을 관측한다. 두 번째 대기가 돌아오기 직전에 임시 잡 JSON을 `running → completed`로 바꾸고 TIMEOUT을 반환한다. 시계는 idle 임계값을 넘게 하고, collect는 같은 seq·살아 있는 PID·자식 없음·빈 입력을 반환한다. 알림/프롬프트만 메모리에 기록해 실제 발행과 세션 조작을 피한다.
**기대:** 완료를 재확인한 감시자는 종료하며 idle 알림·재주입을 하지 않는다.
**실제:** `run()`은 최종 rc 0으로 종료하지만 그 전에 `notifications=['stalled:idle']`, `prompts=[('completed', 'Job … otherwise continue where you left off.')]`를 기록했다. 즉 실제 prompt 호출 시점의 레코드는 이미 completed였다. 래퍼의 정상 stop과도 경합하고, R1로 남은 감시자에서는 더욱 명확히 발생한다. 동일 잡의 다음 dispatch에서도 status만 확인하는 현재 방식은 이전 token의 prompt를 차단하지 못한다.
**수정 방향:** 블로킹 관측 뒤와 재주입 직전에 잡의 비종결 상태 및 token/attempt 일치를 확인하고, 사라짐·불일치에서는 중단한다. 발행 결과를 구조화해 CAS 거부 이후에는 prompt를 보내지 않도록 한다. 완료/redispatch가 관측과 실행 사이에 끼어드는 결정적 테스트를 추가한다. registry 락을 긴 herdr RPC 동안 유지하는 방식은 피한다.
R1·R2의 실행 가능한 격리 탐침은 `/tmp/0f12973f-tools/probes.py`에 남겼다. 검증 cwd에서 브리프의 Python으로 이 파일과 위 검증 경로를 인자로 실행하면 재현된다. 탐침은 실제 서버 대신 자체 모의 herdr를 사용한다.
## 추가 개선 사항 — 비차단
1. **선행 0 예산 처리 불일치.** 래퍼 `:147`의 bash 산술은 `MAM_JOB_TIMEOUT_S=08`을 8진수로 해석하다 오류를 내고 `TIMEOUT=3600`으로 대체한다. 같은 값은 `lib_py/loop/runner.py:600`에서 8로 해석된다. 탐침 결과는 `value too great for base`와 `TIMEOUT=3600`이었다. 공통 Python 양의 정수 파서에서 십진수로 정규화하는 것이 낫다. 정상 정수·CLI 우선순위 테스트는 통과했다.
2. **문서의 오래된 종료 설명.** delegate-job `SKILL.md:99`는 아직 total budget도 `deadline_timeout`이라 설명하고, `:101`의 broker-unavailable deadline 도달도 같은 라벨로 적는다. 구현은 두 경우 모두 wall clock이 소진되면 `wall_clock_timeout`이다. `:100`의 “before v4.7.5”도 이번 4.8.0 릴리스 계획과 맞춰 정리해야 한다. loop `SKILL.md:209`, `:222`의 예산·하위 잡 라벨 반영은 확인했다.
3. **테스트 이식성/격리.** stall watcher 테스트 `:39`의 모듈 전체 Linux skip은 순수 judge·pane parser 테스트까지 숨긴다. /proc·Linux 프로세스 사실을 쓰는 사례만 별도로 표시하는 편이 좋다. 기존 전달 테스트 `test_issue18_liveness_contract.py:497`은 unreachable 환경을 만들기 전에 잡을 등록한다. 잡의 저장된 broker가 환경을 덮어쓰고(`mqtt_common.py:294`), `:511`의 thread join 시간까지 측정하므로 외부 브로커 지연에 따라 시간 assertion이 실패한다. 등록 단계부터 브로커를 격리해야 한다.
## 코드 계약 확인
- `--timeout > MAM_JOB_TIMEOUT_S > 3600`, loop `--job-timeout`, 공통 `contract_args()` 전달 경로와 예산+grace(기본 300초) 대기 상한을 확인했다. 구현·리뷰·수정·재심·중재·순응은 공통 delegate 경로를 사용한다. 명시적 `MAM_CHILD_WAIT_S`는 유지된다. remote dispatch는 `timeout_sec`을 운반하고 두 remote 만료 문구 모두 새 라벨을 쓴다.
- subscriber의 rc 2/4와 bash 상수 일치, `delegate-job wait`의 rc 2 호환, broker-down 재관측 및 disk-heartbeat 유예가 wall deadline에 닿는 라벨을 검토했다. registry close/engine의 새 request 및 하위 잡 `CLOSED-UNCONFIRMED` 분류 테스트가 통과했다. 별도 순수 전이 탐침에서도 `wall_clock_timeout → error` 후 늦은 completed는 `conflicting completed after terminal error; refusing`으로 거부됐다.
- stall watcher의 단일 발행 경로는 `progress`뿐이다. close/error/cancelled 발행이나 자동 승인 경로가 없고, AST 테스트도 이를 확인한다. blocked는 detection 문구를 붙이고 답하지 않는다. idle은 연속 idle 기간, seq 재확인, 어댑터 신뢰도, PID/자손, 빈 입력, 1회 제한을 조합한다. 정상 local watcher 수명, human/remote 제외, `MAM_STALL_WATCH=0`, herdr 장애 경고/종료, loop의 실시간 알림 relay는 모의 환경에서 확인했다. 이 긍정적 결과가 R1·R2의 예외 경로를 보장하지는 않는다.
- 기존 G-8에서 wall=idle=0.5였던 경합을 wall=0.5/idle=30으로 바꾼 것은 타당하다. rc 이름 상수와 CLI help snapshot, issue18 contract_args 기대값 수정도 새 인터페이스에 부합한다. conftest의 process-info shape·detection·seq·wait 지연은 모의 객체에 국한된다.
- 5종 idle pane은 herdr 0.9.1, 2026-09-25 캡처 메타데이터를 가진다. typing 변형은 실측이 아닌 합성이라는 점이 테스트에 명시돼 있다. 실측된 빈 입력과 합성 비어 있지 않은 입력은 모두 통과했다. 실제 Claude 시험 A/B 자체는 이번 브리프의 세션 조작 금지 때문에 재실행하지 않았다. 모의 blocked/idle/자식/입력 조건 검증을 실제 에이전트 실험의 독립 재현으로 부풀리지 않는다.
## bash/Python 경계 표
| 위치 | 새로 수행하는 일 | 경계 판단 / 후속 이전 대상 |
| --- | --- | --- |
| 래퍼 `:146` | env 양의 정수 검증·기본값 선택 | 정책 결정이다. Python 공통 예산 파서로 이전. |
| 래퍼 `:832` | subscriber rc를 job 상태 의미로 매핑 | 프로세스 rc 수집은 bash, 상태/실패 의미 결정은 Python. |
| 래퍼 `:902`, `:950` | 현재 시각과 deadline 비교 후 FINALIZE_CLOCK 선택 | 단순 전달이 아니라 어느 시계가 끝났는지 결정한다. #17 항목 4 대상. |
| 래퍼 `:956` | rc/clock/phase에 따라 close request·사유 선택 | 종결 정책이다. Python이 구조화된 결과를 반환하도록 이전. |
| 래퍼 `:687` | env switch·remote·human 감시 제외 | 현재는 bash에 있는 정책. 장기적으로 Python의 감시 가능 여부 결정과 합칠 수 있다. |
| 래퍼 `:700`, `:711` | 프로세스 시작·kill·wait | bash 책임에 맞는다. R1 수명 정리는 여기서 보강. |
| 래퍼 `:1214` | 외부 wait rc 4를 호환 rc 2로 변환 | 얇은 CLI 경계 변환으로 유지 가능하나 상수/계약은 Python과 일치해야 한다. |
따라서 “bash는 라벨을 나르기만 한다”는 주장은 정확하지 않다. 다만 #17 항목 4 이전이 이미 후속으로 합의됐으므로 이 경계상 부채 자체는 비차단이다.
## 실행 검증 결과
인터프리터는 브리프의 `.venv/bin/python`(macOS Python 3.9.6)을 사용했다. pytest는 `AGENT_PYTHON_BIN` 지정, `PYTHONDONTWRITEBYTECODE=1`, `-p no:cacheprovider`, 검증 트리 밖의 `--basetemp=/tmp/0f12973f-*`로 실행했다.
| 실행 | 결과 |
| --- | --- |
| issue33, issue30, issue18, a4 adapter, tier1, issue5 phase1 — 원본 선택 265건 | 214 passed / 3 failed / 48 skipped, 342.12초 |
| 위 실패 3건만 재실행 | 2 passed / 전달 timing 1 failed, 49.73초 |
| 전달 timing: 잡 등록부터 MQTT=127.0.0.1:9로 격리 | 1 passed, 2.36초 |
| issue30: 외부 pytest plugin으로 모듈 Linux skip을 선택적으로 해제 | 43 passed / 2 failed / 3 skipped, 306.06초 |
| 위 R4 2건: 임시 herdr 모의 스크립트의 프로세스 간 계측만 보정 | 2 passed, 125.18초 |
| base parser 임시 사본을 현재 S14에 적용 | Codex/Grok/OpenCode 3건 예상 실패; agy/claude는 기존에도 통과 |
| 같은 S14에 head parser 적용 | 5건 통과 |
| 정적 검사 | `git diff --check`, 두 bash 진입점 `bash -n` 통과 |
첫 표준 실패 중 2건은 브로커 subscribe/heartbeat 타이밍이었고 재실행에서 통과했다. 나머지 전달 테스트는 위에 설명한 저장된 broker와 thread join의 영향을 분리해 통과를 확인했다. 보조 base archive 실행도 통과했으나 브로커 변동이 있으므로 이것만으로 코드 회귀라고 판정하지 않았다.
강제로 활성화한 R4 2건의 최초 실패 값은 연속 idle 시간이 각각 `-0.00066`, `-0.00741`초였다. Python 3.9.6/macOS의 각 모의 herdr 프로세스에서 monotonic 기준점이 달라지는 계측 문제다. 임시 모의 스크립트에서만 cross-process timestamp를 `time.time()`으로 바꾸고 생산 감시자 코드는 그대로 두자 둘 다 통과했다. 원본 Linux suite가 macOS에서 그대로 통과했다고 주장하지 않는다. Linux process-name 2건과 /proc heartbeat 1건은 검증하지 않았다.
종합하면 선택한 265개 사례 중 262개는 위 재실행/이식 조건에서 통과를 확인했고 3개 Linux 전용 사례는 미검증이다. 별도 R1·R2 탐침은 제품 결함을 재현했다. 테스트 수만으로 PASS를 내릴 수 없다. 검사와 진단용 임시 plugin은 `/tmp/0f12973f-tools/`에 있고 검증 트리는 수정하지 않았다.
## 설계 합의
**A — 동의.** #30은 stalled 판단으로 잡을 닫거나 blocked 승인을 자동 수락해서는 안 된다. 이 PR은 `progress` 단일 발행 경로와 close 경로 부재로 그 원칙을 구현한다. idle의 1회 계속 요청도 합의 범위에 맞는다. 다만 알림 전용이어도 prompt는 외부 부작용이므로 R1·R2의 수명/dispatch 경계를 지켜야 한다. 이번 NOT PASS는 그 실행 안전성 결함이며 알림 전용 원칙에 대한 반대가 아니다.
**B — 동의.** 연장을 #39의 명시적인 프로토콜 요청으로 분리하는 것이 타당하다. 동기 래퍼 호출 중인 러너의 관측 정체를 작업 진행 여부나 연장 승인으로 해석하기 어렵고, seq 정체도 정상 장기 작업에서 생긴다. 요청은 승인이 아니어야 한다. 오케스트레이터가 잡별 누적 최대 예산/횟수를 두고 token·attempt로 인증된 요청을 멱등 처리해 새 절대 deadline을 확정·배포하도록 권한다. 이 규칙을 remote에도 동일하게 적용하면 무한 연장과 양쪽 deadline 불일치를 줄일 수 있다.
**C — 동의.** #40의 트리거는 completed 수신 측이어야 한다. 에이전트 publish_event에 YAML/SQLite 제어 평면 쓰기를 넣으면 워크트리 creator와 `MAM_NO_SUBSCRIBER_DAEMON=1`에서 소유권·경로 문제가 다시 생긴다. 공통 Python 완료 수신 처리기를 두고 래퍼 구독/디스크 완료 관측을 기본 진입점으로 삼는 쪽이 standalone 위임까지 포괄한다. loop `wait_for_job` 직후는 누락 복구 경로로 같은 처리기를 호출할 수 있으며 job/token/attempt 단위로 중복 capture/pin을 막아야 한다. 어댑터는 id_lifecycle·capture·pin 사실/동작을 제공하고, 실행 시점과 제어 평면 쓰기의 권한은 수신 측이 갖는다. #17 항목 4에 흡수하거나 바로 다음 순서가 적절하다.
**D — 조건부 동의.** 이번 PR에서 종결 처리 전체 이전까지 요구하지 않는 것은 범위를 제한한다는 점에서 타당하다. 다만 위 표처럼 bash에 새 정책이 들어간 사실을 인정하고 후속 작업으로 추적해야 한다. #17 항목 4에서 예산 정규화, rc→상태 해석, deadline/idle 판정, FINALIZE_CLOCK와 phase별 close request·사유, 디스크 유예/재관측 종료 결정을 Python으로 옮긴다. bash에는 spawn/wait/kill, 신호 정리 및 얇은 외부 CLI 호환 처리를 남긴다. 이 분리와 별개로 R1·R2는 이번 병합 전에 수정해야 한다.
[VERDICT: NOT PASS]
@@ -0,0 +1,14 @@
{
"loop_id": "loop-20261023T035928Z-2ccc02",
"base_sha": "dc886ecd6b38c01032a55a2d1c230c73362ae630",
"head_sha": "1614af5b673d7519d3a38f63e3636055592b4f14",
"tree_sha": "f1af6fdd548ee5d6f6b81e2202833edfaca98188",
"review_commit": "1614af5b673d7519d3a38f63e3636055592b4f14",
"reviewer": "planner-reviewer-codex-01",
"job_id": "12669b52",
"verdict": "PASS",
"transport": "herdr",
"evidence": "review-tree",
"isolation": "worktree",
"verified_at": "2026-10-23T05:16:58Z"
}
@@ -0,0 +1,21 @@
# Reviewer report — PR #69 / Issue #63
Reviewed the required snapshot at `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261023T035928Z-2ccc02/1`, head `1614af5b673d7519d3a38f63e3636055592b4f14`, against base `dc886ecd6b38c01032a55a2d1c230c73362ae630`. The snapshot matches the expected tree `f1af6fdd548ee5d6f6b81e2202833edfaca98188` and is clean. I read Issue #63 and PR #69. The latest delta is limited to `id_order.py` and its regression tests.
## Previously blocking finding is closed
At the previous head (`7cac6f6`), `_read_and_prune_markers` dropped a dead-owner marker when its session name matched the caller, without checking herdr. The new code removes that exception: `.agents/skills/lib_py/agents/id_order.py:219-230` now keeps a dead-owner marker until its row is registered for that attempt or herdr returns an authoritative `False`; live and unknown answers both retain the orphan. `blockers()` includes a marker without a row even when its name is the caller's (`id_order.py:145-148`), so a retry is refused rather than replacing its marker.
The tests now cover same-name retries with a live owner, a dead owner and live herdr session, unknown herdr state, and a confirmed-gone session (`tests/test_issue63_same_cwd_ids.py:1117-1148`). The real-probe integration test also retries using the orphan's own name and checks that the marker is retained (`tests/test_issue63_same_cwd_ids.py:1287-1290`) and then safely replaced once the session is confirmed gone (`1312-1315`). The test fixture uses a mocked herdr and in-process probes; it does not contact a real herdr server.
## Verification
- `PYTHONDONTWRITEBYTECODE=1 .../python -m pytest -p no:cacheprovider --basetemp=/tmp/mam-review-12669b52 tests/test_issue63_same_cwd_ids.py` — **27 passed** on macOS, Python 3.9.6 (230.59 s). This includes the orphan liveness, unknown-result, zero-wait-cap, and same-name retry cases.
- Changed shell scripts checked with `/bin/bash -n` — passed.
- `git diff --check dc886ecd6b38c01032a55a2d1c230c73362ae630..HEAD` — passed.
- The earlier review covered the broader unchanged stop/purge/resume implementation and adjacent compatibility checks. This re-review did not repeat the base checkout or all adjacent test groups because the new delta is confined to dead-owner marker pruning and its regression tests.
- No real agent-home databases or real herdr sessions were accessed.
The latest change fixes the remaining blocking same-name orphan path. The earlier probe-failure and wait-cap fixes remain covered by the passing Issue #63 tests. I found no blocking issue in this revision.
[VERDICT: PASS]
@@ -0,0 +1,91 @@
# PR #41 수정분 델타 리뷰 — 1b4efe14
**NOT PASS.** N1의 TERM 무시 감시자에 대한 무한 wait는 해결됐고, 새 테스트가 다루는 R1/R2 상황도 통과했다. 다만 블로킹 알림 발행 뒤에는 dispatch를 재확인하지 않아 완료·재디스패치 후 prompt가 여전히 전송된다. 부모 PID를 대기 직후에 확인하지 않는 경로도 남았다. 아래 두 잔여 결함을 작은 후속 수정으로 해결할 수 있으므로 재계획 에스컬레이션은 필요하지 않다.
## 검증 identity
- base: `1673818bb5fed14ed6fee87884e70d39e81fe91f`
- head: `af0a2a2557768fa316203b177e1b90e9f7283a1a`
- tree: `0310682605711b1dc7e31cda84ed6697d8ba6ce8`
- loop: `loop-20260926T052018Z-597343`
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20260926T052018Z-597343/1`
이 경로에서 5개 파일의 delta(+406/−12)를 검토했다. 소스·본 체크아웃을 수정하거나 실제 herdr/에이전트 세션을 조작하지 않았다. 테스트와 탐침은 임시 레지스트리, HOME, 모의 herdr 및 소유 프로세스만 사용했다. 아래 파일:라인은 head 기준이다.
## 남은 차단 결함
### D1 · P2 · R2 미완료 — 알림 발행 중 완료/재디스패치되면 이전 dispatch의 prompt를 보낸다
**근거:** `.agents/skills/lib_py/stall_watch.py:384`에서 `_dispatch_open()`을 확인하지만, `:390`의 `_notify()`가 돌아온 뒤 `:393`은 바로 prompt를 보낸다. `_notify` 내부 `:246`은 별도 발행 프로세스를 최대 120초 기다리는 블로킹 호출이다. 따라서 이것은 마지막 검사와 prompt 사이의 미세한 명령어 경합만이 아니라 네트워크 대기 구간이다.
발행 rc 0은 “현재 dispatch가 아직 열려 있음”을 보장하지 않는다. `.agents/skills/lib_py/delegation/publish_event.py:461`은 wire 발행 전에 progress를 로컬에 기록하고, `:486`의 상태 동기화는 best-effort다. `:508`~`:513`의 반환값은 wire 성공/실패에 따라 정해진다. 발행 도중 completed가 기록되거나 새 attempt가 시작돼도 rc 0이 가능하다. rc 2도 감시자에서는 로컬 기록 성공으로 받아들이므로, 실패 브로커도 최종 dispatch 검사 대신 사용할 수 없다.
**재현 절차:** 임시 UUID4 잡을 running으로 등록하고 실제 `StallWatch._act()`에 idle/seq 일치/자식 없음/빈 입력 Source를 준다. `_notify` 자체는 실제 코드를 사용한다. 그 subprocess 실행 경계만 실제 `publish_event.main()` 호출로 연결하고, publisher의 wire 함수에서 다른 actor의 completed 전이를 끼워 넣은 뒤 성공을 반환한다. 재디스패치 변형에서는 wire 대기 중 completed 이후 임시 레코드를 새 token·attempt 2/running으로 교체한다. 실제 네트워크·세션은 사용하지 않는다.
**기대:** 발행이 끝난 뒤 최신 job/token/attempt를 재확인하여 완료 또는 다른 dispatch면 prompt를 보내지 않는다.
**실제:** completed 변형은 `publisher_rc=[0]`, `record={status: completed, attempt_id: 1}`인데 prompt 1회가 기록됐다. 재디스패치 변형도 `publisher_rc=[0]`, `record={status: running, attempt_id: 2}`인데 이전 잡/attempt의 prompt 1회를 기록했다. 이 탐침은 `_notify=True`만 반환하는 대역이 아니라 실제 발행 로직의 rc를 사용한다.
**같은 경계의 일시 오류 문제:** `_dispatch_open()`의 `:276`~`:277`은 OSError/ValueError를 `True`로 바꾼다. 별도 탐침에서 완료된 임시 레코드의 최종 검사에만 ValueError 1회를 주고 이후 읽기는 정상으로 두자, 실제 publisher가 progress를 rc 0으로 발행하고 prompt가 completed 상태에 전송됐다. 관측 루프를 계속하는 것과 prompt를 허가하는 것은 분리해야 한다.
**수정 방향:** `_notify` 반환 후 실제 prompt 직전에 job/token/attempt를 다시 읽어 확인한다. 읽기 실패는 감시자 전체를 즉시 죽일 필요는 없지만 해당 회차의 재주입은 보류한다. 발행 rc 0/2를 상태 확인의 대체재로 삼지 않는다. wire 발행 중 completed/redispatch, 마지막 읽기의 일시 오류에 대한 테스트를 추가한다. 긴 RPC 동안 registry lock을 잡을 필요는 없다.
### D2 · P2 · R1 잔여 경로 — 부모가 대기 중 사라져도 한 번 더 알림·재주입한다
**근거:** `.agents/skills/lib_py/stall_watch.py:298`은 루프 머리에서만 부모 PID를 확인한다. `:315`의 `wait_once` 뒤 `:332`는 dispatch만 검사하고 부모를 검사하지 않는다. 이어 `_act`의 collect/notify/prompt가 실행된다. transport error 분기 `:316`~`:328` 역시 부모 소멸을 다시 확인하기 전에 경고 발행과 backoff를 수행한다.
**재현 절차:** 실제 `StallWatch.run()`에 모의 wait/Source와 시계를 준다. 첫 wait에서 idle을 관측하고 두 번째 wait 동안 부모가 사라진 것으로 바꾸되 잡은 running으로 유지한다. TIMEOUT 반환 시 idle 임계값을 넘고 입력은 비어 있으며 자식은 없다. `_alive`는 이후 False를 반환한다.
**기대:** wait가 끝나면 부모 소멸을 확인해 rc 0으로 종료하고 새 알림·prompt를 보내지 않는다.
**실제:** `parent_alive=False`, `notices=['stalled:idle']`, `prompts=1` 뒤에야 다음 루프 머리에서 rc 0으로 종료했다. 무기한 고아 문제는 일반 경로에서 해결됐지만, 제어 주체가 없어진 뒤의 부작용은 남아 있다. 또한 `_notify`의 120초 대기와 다른 herdr 호출이 추가될 수 있어 `:300`~`:301`의 “최대 한 번의 wait만 지연”이라는 설명도 현재 제어 흐름 전체에 대해서는 성립하지 않는다.
**수정 방향:** wait에서 돌아온 직후, 오류 알림 전에 부모를 검사하고, collect/발행 같은 블로킹 작업 뒤 prompt 직전에도 검사한다. 부모 소멸 시 잡을 닫지 않고 조용히 종료하는 기존 원칙은 유지한다. 새 R1 테스트의 `MAM_STALL_IDLE_S=600`은 종료만 검증하므로, 부모 소멸과 idle 임계값 도달이 겹치는 사례도 추가한다.
실행 가능한 D1(완료/재디스패치)·D2 탐침: `/tmp/1b4efe14-tools/probe_notify_gap.py`. 검증 cwd에서 브리프의 Python으로 실행하고 위 검증 경로를 인자로 전달한다.
## 요청 항목별 판단
**N1 종료 대기 — 해결.** 래퍼 `:710`~`:731`의 TERM→25×0.2초 폴링→KILL→wait는 TERM을 무시하는 정상 OS 자식의 무한 wait를 제거한다. 5초는 TERM 유예 시간이며 fork/scheduling/회수까지 정확히 5.000초 이하라는 뜻은 아니다. KILL 뒤 자신의 자식을 wait하는 부분에서 이번 결함과 같은 TERM 무시 경로는 남지 않는다. 빈 STALL_PID는 바로 반환하고, 이미 끝난 rc 7 자식은 오류를 보고한 뒤 반환하는 것을 별도로 확인했다. 커널 자체가 회수 불가능한 프로세스 같은 일반 OS 제한을 이번 PR의 새 차단 사유로 삼지 않았다.
**watch-pid 방식 — 조건부 수용.** trap 합성 자체가 필수는 아니다. 래퍼가 양의 실제 `$$`를 넘기고 Python이 소멸을 관측하는 방법은 SIGKILL에도 대응할 수 있다. `_alive`의 PermissionError→True, ProcessLookupError→False를 확인했다. 단, 정상 herdr CLI가 timeout 안에 반환한다는 가정이 있으며 `watcher.py:165`에는 독립 subprocess timeout이 없다. 이번 수정의 문제는 D2의 관측 위치다. “TERM에서도 trap을 전혀 실행할 수 없다”는 설명은 정확하지 않지만, trap 부재 자체로 NOT PASS를 내지는 않는다. 문서의 “exactly as long”은 검사 지연을 반영해 완화하는 편이 정확하다.
**dispatch 검사와 blocked 알림 — 부분 수용.** 없는 레코드를 gone으로 보는 것은 맞다. 일시 오류 때 관측을 계속하는 것도 가능하지만 `True`로 prompt까지 허용하는 것은 D1에서 고쳐야 한다. token/attempt 불일치 검사는 정상 레코드에서 유효하다. blocked는 자동 응답 없이 progress만 보내므로, detection 조회 중 상태가 바뀌어 뒤늦은 진단 1회가 남는 것 자체는 이번 재주입 결함과 같은 차단 사유로 보지 않는다. 그래도 부모 소멸 후 추가 발행은 D2의 공통 검사로 막는 편이 좋다.
**새 테스트 — 유효하지만 경계가 좁음.** stop 함수 자체의 TERM 무시 대역과 실제 래퍼/shim 경로를 함께 검증하는 접근은 좋다. 부모 소멸과 wait 중 완료/redispatch, 발행 거부의 7건은 현재 코드에서 통과했다. 반면 R2 테스트는 `_notify`를 즉시 True/False를 반환하는 메모리 대역으로 바꾸므로 실제 wire 대기 중 상태 변경을 검증하지 않는다. 정상 테스트 경로의 프로세스 정리는 확인했지만 실패 경로 정리는 보완할 만하다. 예를 들어 `tests/test_issue30_stall_watch_stop.py:162`의 assert가 실패하면 `:163`의 stand_in.kill에 도달하지 않으며 finally는 watcher만 죽여 sleep 300이 남을 수 있다. 이 점은 테스트 유지보수 비차단 사항이다.
**M8 — 해결.** /proc에 의존하는 S6만 skip으로 좁히고 R4 모의 프로세스 사이의 timestamp 비교를 time.time으로 바꾼 것은 이 macOS/Python 3.9 환경에 맞다. 생산 감시자의 연속 idle 판정은 여전히 단일 프로세스 monotonic 시계를 쓰므로 바뀌지 않는다. S5 상주 자식 두 사례도 이번에는 원본 테스트로 실행됐다. 주석은 모든 macOS/Python 버전으로 일반화하기보다 지원 환경에서의 프로세스 간 계측 문제로 설명하는 것이 더 정확하다.
## 1회차 비차단 항목 처리표
| 1회차 항목 | 이번 상태 | 판단 |
| --- | --- | --- |
| 선행 0 예산 `MAM_JOB_TIMEOUT_S=08`의 bash/Python 불일치 | 미반영 | #17 항목 4의 공통 정수 파서로 넘길 기존 비차단 부채. |
| delegate SKILL의 idle/total-budget/broker 라벨 및 v4.7.5 문구 | 닫힘 | 새 표가 idle과 wall clock을 구분하고 v4.8.0으로 정정됐다. |
| stall 테스트 모듈 전체 Linux skip | 닫힘 | S6만 skip. 순수·모의/프로세스 테스트가 macOS에서도 실행된다. |
| R4 모의 프로세스 사이 monotonic 기준점 문제 | 닫힘 | 모의 timestamp만 교체됐다. |
| 기존 issue18 전달 테스트의 등록 broker/네트워크 timing 의존 | 미반영 | 이번 delta에 해당 fixture 수정 없음. B3 공개 브로커 지연(M9)과 함께 별도 격리 개선 대상. 이번 판정의 차단 근거 아님. |
| bash의 예산 검증·FINALIZE_CLOCK·종결 정책 | 후속 유지 | #17 항목 4 이전 대상. 이번 TERM/KILL/wait는 외부 프로세스 관리라 bash 책임에 맞는다. |
## 검증 결과
지정된 네 모듈을 검증 경로에서 브리프의 worktree `.venv/bin/python`과 `AGENT_PYTHON_BIN`으로 실행했다. `PYTHONDONTWRITEBYTECODE=1`, `-p no:cacheprovider`, 외부 `--basetemp=/tmp/1b4efe14-suite`를 사용했다.
| 실행 | 결과 |
| --- | --- |
| `test_issue30_stall_watch_stop.py`, `test_issue30_stall_watch.py`, `test_issue14_delivery_ack.py`, `test_tier1_unit.py` — 134건 | 132 passed / 1 failed / 1 skipped, 590.08초 |
| 실패한 `test_wrapper_idle_timeout_grace_ignores_pane_busy_token`만 별도 basetemp에서 재실행 | 1 passed, 27.69초 |
최초 실패는 `tests/test_issue14_delivery_ack.py:350`에서 started 발행이 브로커 CONNACK을 세 차례 받지 못해 rc 2를 반환한 경우다. 재실행에서 통과했으며 제품 결함 판정에 포함하지 않았다. 선택한 사례 기준 133건의 통과를 확인했고, S6(/proc) 1건은 이 macOS 환경에서 미검증이다. 새 stop 7건, 기존 stall 47건은 원본 코드/테스트 그대로 통과했다. 전체 실행과 재실행 후 이번 임시 경로를 사용하는 감시자·heartbeat 프로세스가 남지 않았는지 확인했다.
추가 검증은 다음과 같다.
- 임시 base 모듈로 새 R2 테스트 3건 실행: 완료 중 wait, 재디스패치 중 wait, 발행 거부 모두 예상한 AssertionError로 실패. head의 해당 3건은 원본 suite에서 통과.
- base의 stop 함수를 임시 프로세스 그룹에서 실행: TERM 무시 자식을 기다리며 8초 후에도 반환하지 않음. 탐침 그룹을 KILL하고 회수했다. head의 상한 테스트는 원본 suite에서 통과.
- head의 empty PID/이미 종료 rc 7, `_alive`의 PermissionError/ProcessLookupError 4개 추가 확인 통과.
- 실제 publisher 로직을 통과하는 완료/재디스패치 중 알림 탐침 2건, 부모 소멸 중 wait 탐침 1건, 일시 읽기 오류 뒤 completed prompt 탐침 1건은 위 결함을 재현했다. 실제 MQTT나 herdr 세션에 발행하지 않았다.
- `git diff --check 1673818`, 래퍼 `bash -n` 통과. 검증 트리 HEAD/tree 및 깨끗한 상태를 재확인했다.
이전 설계 합의(알림 전용, #39 분리, completed 수신 측 #40 트리거, #17 항목 4 후속)는 유지한다. 이번 NOT PASS는 설계 의견이나 미반영 비차단 사항이 아니라 재현된 D1·D2에 근거한다.
[VERDICT: NOT PASS]
@@ -0,0 +1,56 @@
# PR #51 round 2 delta review — Codex
Job: 24b21b9c
Loop: loop-20261007T162612Z-9fe4f9
Base: 518bedac5d8ab832347bfc6682b146f3201021f1
Head: 4c86107846d1e037f9a69a1201257725e8673179
Tree: e6f67f6136b8f0cec15ce98400ae7d7710cedf1e
Validation path: /var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261007T162612Z-9fe4f9/1
## Conclusion
R1 is closed on code inspection and deterministic execution. No new code defect was found in the two-file delta. Overall approval is withheld solely because the required two-file suite could not execute its test bodies under this session's network restrictions. This is a validation/environment blocker, not a request to change the implementation. Run the required suite in a permitted isolated environment before promoting this review to PASS; no design escalation is needed.
## R1 closure table
| Item | Evidence | Result |
| --- | --- | --- |
| 1-second wait after a 10-second backward wall-clock step | wrapper.py:150,153; independent base/head comparison: 11.0 seconds -> 1.0 second | Closed |
| 3-second wait after the same step | wrapper.py:150,153; independent base/head comparison: 13.0 seconds -> 3.0 seconds | Closed |
| Broker re-observation consumes the corrected wait | wrapper.py:211; finalize_end with completion appearing during the wait: base 13.0 seconds, head 3.0 seconds, both return completed | Closed |
| Heartbeat grace consumes the corrected wait | wrapper.py:239; finalize_end with completion appearing during the wait: base 11.0 seconds, head 1.0 second, both return completed | Closed |
| Owner polling retained | wrapper.py:152,156; injected owner loss observes checks at elapsed 0.0, 0.5, 1.0 seconds and raises OwnerGone at 1.0 | Preserved |
| Deadline retains wall-clock policy | wrapper.py:203,226,240; forward wall-clock jump during the 1-second grace produces wall_clock_timeout at elapsed 1.0 second | Preserved |
File references above are relative to `.agents/skills/lib_py/delegation/` in the validation tree.
## Scope and test quality
Only `.agents/skills/lib_py/delegation/wrapper.py` and `tests/test_issue17_item4_decisions.py` changed (43 insertions, 3 deletions). Production changes are restricted to the `_sleep` docstring and its two clock reads. `_now`, heartbeat timestamp age, evidence windows, START_TIME + TIMEOUT, subprocess/registry/event handling, and surrounding branch logic are unchanged.
The regression tests at tests/test_issue17_item4_decisions.py:598-630 use separate simulated wall and monotonic clocks, step wall time backward once, and advance in deterministic half-second increments. They perform no real sleep or clock adjustment. Both 1- and 3-second cases distinguish the faulty base from the fixed head decisively. The extra owner/deadline probes cover behavior not asserted by these two new tests.
Previous nonblocking follow-ups and the unrelated main-branch restore failure were not reopened.
## Validation results
- Required suite attempted with Python 3.9 from `/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux-wt/pr-51/.venv/bin/python`, PYTHONDONTWRITEBYTECODE=1, pytest cache disabled, and external basetemp `/tmp/24b21b9c-selected`.
- Targets: `tests/test_issue17_item4_decisions.py tests/test_issue33_budget.py`.
- Result: **278 setup errors in 1.80s**, exit 1; no passing test-body claim is made for this run. Every case is blocked by the session-autouse local MQTT fixture: tests/conftest.py:1575 calls start_broker, then tests/_local_mqtt.py:90 fails at `self._lsock.bind(("127.0.0.1", 0))` with `PermissionError: [Errno 1] Operation not permitted`.
- Required-suite log: `/tmp/24b21b9c-selected.log`.
- Separate network-free run: `python -m pytest --noconftest -p no:cacheprovider -q --basetemp=/tmp/24b21b9c-unit-tests tests/test_issue17_item4_decisions.py::test_u_a_wait_is_not_stretched_by_a_wall_clock_step_back` -> **2 passed in 0.08s**. This deliberately excludes the broker autouse fixture and is supplementary evidence, not a substitute claim for the required suite.
- Independent probe script `/tmp/24b21b9c-probes.py`: all assertions passed for base/head sleep, both finalize paths, forward wall-clock expiry, and owner polling. Actual system time was never changed; side effects and disk reads were stubbed for finalize probes.
- Both changed Python files passed AST parsing; `git diff --check BASE HEAD` passed.
- HEAD/tree matched the brief before and after validation; final `git status --porcelain` was empty.
## Reproduction and resolution of the validation blocker
Use the brief's required two-file pytest command in this restricted session. Expected: the isolated local broker starts and the 278 test bodies execute. Actual: binding the local TCP listener is denied before any test body runs. Resolution: rerun the same suite in an environment that permits its isolated loopback broker; do not modify product code, suppress the integration fixture, or classify setup errors as passing tests.
## Isolation and lifecycle
Before tests and probes, inherited HERDR_*, MAM_*, MQTT_*, SKS_* and control-plane binding variables were removed; temporary HOME, HOME_DIR, WORKSPACE_ROOT, XDG_CONFIG_HOME and absent MAM_ENV_FILE were supplied. The required-suite harness additionally set WORKSPACE_ROOT to each test's tmp_path via an environment-only autouse plugin, so sourcing helpers cannot generate a shim in the review snapshot or another workspace. No .mam.env was exported, no live herdr server was used, and no source files, branches or commits were changed. Only the requested report and lifecycle publisher used this job's real control-plane paths.
The requested started command was attempted first, but MQTT publication exhausted three attempts and exited 2 with Operation not permitted. The requested completed command was also executed after saving this report; all three MQTT attempts failed with Operation not permitted and the command exited 2. The report is saved, but neither lifecycle event was successfully delivered to the broker.
[VERDICT: NOT PASS]
@@ -0,0 +1,14 @@
{
"loop_id": "loop-20261015T123502Z-1b9df6",
"base_sha": "e32c9574644a9185813a4dc881812694d62b6204",
"head_sha": "a7cb90e63abf454ccf22f6ff887cc25393755402",
"tree_sha": "96c5c5f82cb89e4383011d52fb1dcb21d4e787e6",
"review_commit": "a7cb90e63abf454ccf22f6ff887cc25393755402",
"reviewer": "planner-reviewer-codex-01",
"job_id": "2e06828a",
"verdict": "PASS",
"transport": "herdr",
"evidence": "review-tree",
"isolation": "worktree",
"verified_at": "2026-10-15T13:18:35Z"
}
@@ -0,0 +1,68 @@
# PR #58 Mac 2차 리뷰 — PASS
Job `2e06828a`, Reviewer. 이전 네 지적 사항(F1/F2/R1/R2)이 해결됐음을 확인했다. 관련 테스트 83건이 통과했으며, 수정 직전 커밋에서는 해당 회귀 테스트 6건이 각각 예상한 이유로 실패했다. 이번 검토 범위에서 새 차단 이슈는 발견하지 못했다. 소스 수정은 하지 않았다.
## 검증 identity
- loop: `loop-20261015T123502Z-1b9df6`
- base: `e32c9574644a9185813a4dc881812694d62b6204`
- head: `a7cb90e63abf454ccf22f6ff887cc25393755402`
- tree: `96c5c5f82cb89e4383011d52fb1dcb21d4e787e6`
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261015T123502Z-1b9df6/1`
- 누적 범위: base..head 15 commits / 23 files. 이전 검토 head `63d8164` 이후 4개 수정 커밋을 집중 검토하고 누적 변경과의 정합성을 대조했다.
- 실제 환경: macOS, Python 3.9.6, GNU Bash 3.2.57(arm64-apple-darwin25).
## 지적 사항별 확인
| 항목 | 수정 전 검증 | 현재 head 검증 | 판정 |
|---|---|---|---|
| F1: paho 없는 Python 경로 계산 | `63d8164`에서 binding 1건 및 status json/text 2건이 ModuleNotFoundError로 실패 | 같은 3건 통과. 별도로 실제 `/usr/bin/python3`의 paho spec이 None임을 확인하고 `mam_control_plane_json` 및 status의 두 `get_job_status` 함수를 실행해 성공 | 해결 |
| F2: 다른 cwd의 기본 logs | `75c0042`에서 WORKSPACE_ROOT의 잡 대신 elsewhere/.mam 로그 루트를 조회하여 실패 | WORKSPACE_ROOT=ws, cwd=elsewhere에서 `logs --list`와 `logs <id>` 모두 잡/registered 이벤트 조회 성공 | 해결 |
| R1: 다른 dispatch 완료 오인 | `151af99`에서 A error → redispatch → B completed interleaving이 A를 completed로 종료시켜 실패 | 동일 interleaving에서 A는 pending 유지, terminal 비어 있음. B 토큰으로 다음 조회 시 completed. 별도 탐침도 동일 결과 | 해결 |
| R2: registry 자체의 symlink | `1c598bb`에서 alias/jobs의 sibling이 actual이 아닌 alias로 계산되어 실패 | alias/jobs → actual/jobs에 submit --dry-run 후 같은 alias 인수로 logs 조회 성공. alias 옆에는 로그를 생성하지 않음 | 해결 |
수정 전 테스트는 검증 저장소에서 `git archive <각 수정 커밋의 부모>`로 만든 임시 복사본에서 실행했다. 해당 복사본에는 현재 head의 `tests/test_issue57_logs_follow_registry.py`만 복사해 새 회귀 테스트를 실행했다. 실제 코드의 이전 동작 때문에 실패했으며 수집 오류나 환경 오류로 실패한 것이 아니다. 원본 검증 트리나 creator 브랜치는 변경하지 않았다.
각 회귀 테스트:
- F1: `test_control_plane_binding_works_without_paho`, `test_status_works_without_paho[json]`, `test_status_works_without_paho[text]`.
- F2: `test_logs_without_registry_dir_follows_the_workspace_from_any_cwd`.
- R1: `test_disk_fallback_rejects_a_terminal_that_belongs_to_a_later_dispatch`.
- R2: `test_a_symlinked_registry_dir_resolves_to_one_audit_log`.
## 코드 및 추가 탐침 근거
F1: `.agents/skills/lib_py/delegation/logroot.py`가 표준 라이브러리만으로 경로 규칙을 정의한다. `control_plane.py`, lib.sh의 경로 helper, status.sh가 이 모듈을 사용한다. 패키지 초기화의 전이 import도 `mqtt_common.py:33` 및 `publisher.py:17`의 선택적 paho import로 통과한다. 실제 paho 미설치 `/usr/bin/python3` + `AGENT_PYTHON_BIN=/usr/bin/python3`에서 lib.sh의 `mam_control_plane_json`을 실행했다. status.sh의 두 `get_job_status` 정의를 그대로 추출해 동일 인터프리터에서 audit-only completed 레코드를 읽었으며 둘 다 `('probejob', 'completed')`를 반환했다. 경로 조회가 MQTT 접속을 필요로 하지 않고, 실제 client 생성은 paho가 없으면 명시적으로 실패한다.
F2: wrapper의 `cmd_logs`는 `--registry-dir`가 명시된 경우에만 배열에 넣어 registry.py로 넘긴다. 기본 실행은 registry.py의 MAM_JOBS_DIR → WORKSPACE_ROOT → cwd 규칙을 사용한다. 비어 있는 배열은 `${reg[@]+"${reg[@]}"}` 형태로 전개되므로 Bash 3.2 + set -u에서도 동작한다. 기본 인수 및 명시적 registry 인수 두 경우 모두 실제 테스트로 통과했다.
R1: `job_subscriber.py:128`에서 감사 상태를 읽은 뒤 레지스트리를 재조회하고 `:132`에서 dispatch_token/attempt_id/transition_seq를 비교한다. 첫 관찰과 다르면 그 라운드에서는 채택하지 않는다. 따라서 정상 엔진이 토큰을 교체하고 seq를 증가시키는 재디스패치가 감사 read 전후에 끼어들어도 이전 토큰에 다음 시도의 성공을 붙이지 않는다. 추가 재조회 이후에 새로운 전이가 일어나더라도 이미 읽어 둔 감사 상태를 새 상태로 바꾸지는 않는다. 기존 레지스트리 terminal 경로는 추가 read 없이 처리한다. 감사 상태를 채택할 후보에만 파일 read 최대 1회가 추가되며 새 내부 polling loop, sleep, 장시간 lock은 없다. 성능 판단은 이 코드 경로에 근거하며 대규모 잡 부하 벤치마크를 수행했다는 뜻은 아니다.
R2: `logroot.py:51`이 registry의 realpath에서 sibling을 구한다. submit의 `pwd -P`, control_plane의 realpath와 일치한다. `control_plane.py:82`의 비교도 로그 경로 양쪽을 정규화한다. 추가 탐침에서 `/tmp`/`/private/tmp`, `/var`/`/private/var` 부모 별칭 및 lexical 별칭을 사용하는 기존 row에서도 파생 로그 키를 제외하는 동작이 일치했다. 명시적 DELEGATE_JOB_LOGS_DIR 우선권은 유지된다. 일반 기본 경로는 같은 물리적 위치이며 Mac에서 문자열의 `/private` 접두사가 정규화되는 것은 의도된 변화이다.
기존 import 호환성: 실제 `/usr/bin/python3`에서 `mqtt_common.sibling_logs_dir`, `get_logs_dir`, `default_registry_dir`가 logroot의 해당 함수와 동일 객체임을 확인했다. venv Python에서 `lib_py.loop.eager.eager_load()` 실행 후 `lib_py.delegation.logroot`가 검증 트리 경로로 sys.modules에 적재됐음을 확인했다. 명시적 eager 목록에 logroot를 중복 추가하지 않아도 mqtt_common의 최상위 import를 통해 시작 시 적재된다.
이전 R1/R2 임시 탐침 파일은 더 이상 존재하지 않아, 이전 리포트에 기록한 동일한 전이 순서 및 symlink submit/logs 절차를 새 임시 스크립트로 재구성했다. R1은 실제 engine 전이를 사용하고 audit read 경계에만 순서를 삽입했으며, R2는 실제 wrapper의 dry-run/logs를 호출했다.
## 실행 결과와 한계
- 현재 head 선택 테스트: **83 passed in 181.30s**, exit 0.
- 모듈 전체: `test_issue57_logs_follow_registry.py`, `test_issue15_registry_isolation.py`, `test_issue12_registry_secrets.py`, `test_issue21_worktree_prereqs.py`, `test_issue21_acceptance.py`.
- 개별 테스트: tier1의 `test_b9_logs_dir_follows_cwd_changes`; issue18의 `test_subscriber_records_seq_gaps_and_recovers_terminal_from_state`, `test_subscriber_state_renews_liveness_only_when_it_advances`, `test_remote_runner_ignores_a_resent_dispatch_of_a_finished_attempt`, `test_remote_runner_never_rolls_a_newer_attempt_back`.
- 변경 Python 파일 전체에 실제 Python 3.9.6의 AST parse 적용, 변경 shell/wrapper 각각 `/bin/bash -n` 검사 통과. `git diff --check base HEAD` 통과.
- 테스트마다 브리프의 지정 환경변수를 `env -u`로 제거했고, 추가로 에이전트 관련 환경과 HOME/XDG 위치를 격리했다. 지정 venv Python, `PYTHONDONTWRITEBYTECODE=1`, `-p no:cacheprovider`, 검증 트리 밖 basetemp 사용. create/resume은 mock fixture로 검증했다.
- 실제 herdr 서버/monitor/에이전트를 시작하지 않았다. 실제 제어 평면에는 지시된 job lifecycle 이벤트와 본 리포트만 기록했다. creator 작업 트리에서 실행하지 않았다.
- 전체 스위트는 이 리뷰에서 다시 실행하지 않았다. 오케스트레이터의 별도 전체 스위트 결과를 이 리뷰 결과로 포함하지 않았다. 선택 테스트에서 #55 가드 오탐 및 teardown 오류는 없었다.
- 최종 검증 트리 HEAD/tree는 브리프와 일치하고 git status는 clean이다.
검증 산출물 디렉터리: `/tmp/review-2e06828a-66fn_mpg/`.
- `before-F1.log`: 3 failed, 각각 paho import 실패.
- `before-F2.log`: 1 failed, 잘못된 cwd 로그 루트.
- `before-R1.log`: 1 failed, B 완료를 A에 귀속.
- `before-R2.log`: 1 failed, alias와 actual sibling 불일치.
- `head.log`: 83 passed.
- `system-probe.py`, `system-probe.log`: 실제 시스템 Python/no-paho 및 import/eager/구문 검증.
- `repro-r1-r2.py`, `repro-r1-r2.log`: 이전 두 재현 절차 및 Mac 별칭 검증.
[VERDICT: PASS]
@@ -0,0 +1,66 @@
# PR #37 독립 리뷰
- Job: `2e264c0c`; reviewer: `planner-reviewer-codex-01`
- Loop: `loop-20260924T080014Z-2b3df0`
- Base: `1debc7d92444385b9200e821efa0ff0f6d82023e`
- Head: `59be1c89209a232059aec0c5aeecc2329929cb49`
- Tree: `a009c9062d9818f343ac57e6c72cda932887fab7`
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20260924T080014Z-2b3df0/1`
파일 전달의 정상 경로와 기존 계약은 검증됐지만, Python 러너의 파일 쓰기 실패 정리 누락과 새 테스트의 macOS 실패가 남아 있다. 아래 두 항목을 수정한 뒤 재검토가 필요하다. 계획된 4.7.3 버전 변경과 별개의 지적이다.
## R1 — P2: Python 러너의 임시 파일 쓰기 실패가 정리 범위 밖에 있음
근거: `.agents/skills/lib_py/loop/runner.py:472`–`:477`, `:490`–`:502`.
`NamedTemporaryFile(delete=False)` 생성과 `fh.write()` 및 context manager 종료 시 flush/close가 `try/except/finally`보다 먼저 실행된다. 따라서 쓰기 또는 flush가 실패하면 `OSError`가 `delegate_job()` 밖으로 전파되고, 뒤의 `os.unlink(prompt_file)`에 도달하지 않는다. 특히 경로 변수도 쓰기 다음에 지정되어 있어 단순히 try 위치만 바꾸는 것으로는 충분하지 않다. 새 S9는 셸 러너의 부분 파일 정리만 검사하므로 이 Python 경로를 검출하지 못한다.
재현 절차:
1. 독립 Python subprocess에서 `WORKSPACE_ROOT`, `HOME`, 로그 경로를 새 `/tmp` 샌드박스로 설정하고, 검증 트리의 `LoopRunner(['--creator', 'c', '--task', 't'])`를 생성한다.
2. 그 subprocess에서만 `signal.signal(signal.SIGXFSZ, signal.SIG_IGN)`과 `resource.setrlimit(resource.RLIMIT_FSIZE, (1024, 1024))`를 적용한다.
3. `runner.delegate_job('c', 'claude', 'Worker', 'x' * 20000)`를 호출하고 예외와 `.mam/prompt-*`를 확인한다. 실제 wrapper/에이전트가 실행되기 전에 파일 쓰기에서 실패한다.
기대: 원인을 기록하는 정상적인 제출 실패 반환과 임시 파일 정리.
실제: `[Errno 27] File too large`가 처리되지 않은 `OSError`로 전파됐고, `.mam/prompt-loop-…md`가 **1,024바이트, 권한 0600**으로 남았다. 실제 `LoopRunner.delegate_job()`으로 재현했으며, 오류를 흉내 내는 subprocess mock은 사용하지 않았다. 생성/쓰기 실패를 처리해야 한다는 이번 브리프의 요구를 충족하지 못한다.
수정 방향: 임시 파일 경로를 미리 초기화하고, 디렉터리 생성·파일 생성·쓰기·close·wrapper 실행을 정리 가능한 하나의 try/finally 범위에 둔다. 생성된 파일의 이름은 `write()` 전에 저장한다. 파일 준비 실패도 원인이 드러나는 실패 제출 결과로 돌려주고, 첫 시도와 busy 재시도에서 같은 규칙을 적용한다. 메시지는 wrapper 실행 실패와 파일 준비 실패를 구분하는 것이 정확하다.
대안 검증: 검증 트리에서 추출한 동일 `_run_cmd`를 임시 harness에서만 변경해, 위 작업들을 기존 예외/정리 범위 안으로 옮기고 경로를 쓰기 전에 저장했다. 같은 실제 파일 크기 제한에서 원본은 예외와 1,024바이트 파일을 남겼지만, 후보는 **rc 126 실패 결과와 빈 leftovers 목록**을 반환했다. 제품 코드는 수정하지 않았다. 이 상황을 Python 러너의 회귀 테스트에도 추가해야 한다.
## R2 — P2: 새 S9가 macOS에서 플랫폼별 오류 문구 때문에 실패함
근거: `tests/test_issue36_prompt_transport.py:602`.
재현: 브리프의 인터프리터와 격리 옵션으로 `tests/test_issue36_prompt_transport.py::test_s9_shell_loop_removes_a_partial_prompt_file_and_says_why`를 실행한다.
기대: 실제 `RLIMIT_FSIZE` 쓰기 실패, 비정상 종료, 부분 파일 제거, 프로젝트가 출력하는 원인 메시지를 검증하며 지원 플랫폼에서 통과해야 한다.
실제: 전체 선택 실행과 S9 단독 재실행 모두 `assert "File too large" in result.stderr`에서 실패했다. 이 macOS 실행 환경의 bash stderr에는 그 문구가 없었다. 프로젝트 자체의 `delegate_job_safe: cannot write the prompt file ...` 메시지는 출력됐다.
수정 방향 및 검증: 플랫폼별 bash/OS 오류 문자열을 필수 계약으로 삼지 말고, 비정상 반환 코드·부분 파일 없음·프로젝트의 고유 오류 메시지를 유지해서 검사한다. 임시 namespace에서 해당 영어 문자열 assertion 한 줄만 제외한 동일 S9를 새 샌드박스에 실행했으며, 나머지 **실제 쓰기 실패와 정리/진단 assertion은 모두 통과**했다. 테스트 전체를 skip하거나 정리 assertion을 제거할 필요는 없다.
## 나머지 검토 결과
- 전달 경로: `--prompt` 잔여 호출자를 직접 검색했다. 옛 셸 러너의 11개 내부 호출은 shell 함수 `delegate_job_safe`로 들어가며, 이 함수가 exec 전에 파일로 변환한다. Python 러너, lib.sh의 Python/legacy 제출 도우미, delegation CLI, wrapper의 registry register/update 경로는 파일 전달을 사용한다. 명시적 `--prompt` CLI 호환 경로는 남아 있다.
- 경로/크기: 실제 이 호스트의 `SC_ARG_MAX`보다 큰 **1,114,112바이트** 프롬프트를 argv로 전달하면 `OSError(errno=7)`가 발생했다. 같은 내용은 상대경로의 공백 포함 파일명, 다른 `--workdir`, 공백 포함 registry 경로 조합으로 실제 wrapper/registry를 거쳐 원문 그대로 등록됐다. Linux 단일 인자 131,072바이트 상한을 macOS에서 재현했다고 주장하는 것이 아니다.
- 파일 읽기 시점: wrapper가 registry를 동기 실행하고 registry가 그 호출 중 파일 내용을 읽으므로, 호출자 쪽 정상 반환 후 임시 파일 삭제와 충돌하지 않는다. 정상 완료/exec OSError의 정리 테스트는 통과했다. R1은 그보다 앞선 쓰기 실패에 한정된다.
- 실패 처리: exec 실패와 busy 재시도의 rc 126 처리, 재심/중재/순응 미등록 시 비정상 종료 테스트가 통과했다. 별도 실제 루프 subprocess에서 fake agent가 순응 잡을 등록한 뒤 `error` 상태로 종료하도록 했을 때도 rc 1로 끝났고 다음 리뷰 라운드/성공 승격으로 진행하지 않았다. 구현·일반 수정 잡의 실패를 중단하는 기존 정책과 일관된 강화다. 다만 이때도 “Failed to register Creator compliance job”이라고 출력하는 문구는 등록 실패와 실행 실패를 구분하지 못한다(비차단 진단 개선 사항).
- diff 제한: 수집기의 바이트/줄 제한값은 바뀌지 않았고 증거 절단 표시도 유지된다. L15는 잘린 증거를 받는 diff-only 원격 리뷰어를 거부하고 로컬 리뷰는 허용하는 기존 규칙을 검증했다.
- create 온보딩: S7 계열과 새 S8이 통과했다. brief는 job registry 아래 절대경로로 만들어지고, 주입문은 과제 본문 대신 그 경로와 기존 dispatch-token/started 명령을 포함한다. 임시 제출 파일과 에이전트가 이후 읽을 영속 brief는 구분된다.
- 하위 호환: registry CLI 계약 스냅샷, shim help 캡처, loop CLI, worktree 문맥, Issue #14 acknowledgement 선택 테스트가 통과했다. `--prompt`는 계속 사용할 수 있다. 대표 역검증으로 샌드박스의 wrapper만 base 버전으로 교체하면 새 S1은 `unknown option: --prompt-file`로 실패했다. PR이 언급한 13건 전체의 과거 버전 실패 수를 재측정한 것은 아니다.
- 문서: delegate-job skill은 파일 옵션과 Linux argv 제한을 설명하고, loop skill은 위임 실패의 비정상 종료 및 pipefail 사용을 설명한다. 검증 트리에 `.claude/skills` 배포 사본은 없었다. 4.7.3 락스텝 bump는 브리프대로 오케스트레이터 후속 작업이다.
## 실행 증거
모든 pytest 실행은 검증 경로에서 브리프의 절대 Python 경로를 `AGENT_PYTHON_BIN`과 실행 명령에 함께 지정하고, `PYTHONDONTWRITEBYTECODE=1`, `-p no:cacheprovider`, 검증 경로 밖의 고유 `--basetemp`를 사용했다.
1. `test_issue36_prompt_transport.py`, `test_issue5_phase1_delegation.py`, `test_loop_cli.py`: **95 passed, 1 failed, 1 skipped — 91.15초**. 실패는 R2의 S9이며, skip은 Linux 전용 MAX_ARG_STRLEN 음성 대조군이다.
2. `test_issue23_24_startup_path.py`, `test_issue21_loop_b.py`, `test_issue21_worktree_prereqs.py`, `test_issue14_delivery_ack.py`에서 `s7 or l15 or t2_5 or wrapper_honours_registry_ack` 선택: **19 passed, 132 deselected — 63.39초**.
3. S9 단독 확인: 동일 문구 assertion에서 실패. 위 결과에 중복 합산하지 않았다.
4. `git diff --check`, 변경 셸 파일 5개의 개별 `bash -n`, 변경 Python 파일 4개의 AST 구문 검사 통과.
HEAD/tree가 브리프와 일치했고 검증 후 git 작업 트리도 깨끗했다. 제품 코드, creator 워크트리, 실제 herdr 서버/에이전트 세션을 수정하지 않았다. 제안 검증과 역검증은 임시 사본/독립 subprocess에 한정했다. 두 지적은 국소 수정으로 해결 가능하므로 재설계 에스컬레이션은 필요하지 않다.
[VERDICT: NOT PASS]
@@ -0,0 +1,112 @@
# PR #43 review — job 3001f1d3
판정: **NOT PASS**. 로컬 MQTT 전환의 검증은 강화됐지만, 설정 파일 읽기 실패 처리와 두 정수 정규화 구현의 동작에 재현 가능한 차이가 남아 있다. 아래 수정은 현재 설계 안에서 가능하므로 재계획 에스컬레이션은 요청하지 않는다.
## 검증 identity와 범위
- loop: `loop-20261001T131705Z-a60092`
- base: `e1eb22bd59ce825e3a22e86433c30f7ffd833810`
- head: `b58edace115205500c1197d9ce59cb5c6d724893`
- tree: `14b670fbfc82dc9179cab43cf53eef920a3d6ffa`
- 검증 cwd: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261001T131705Z-a60092/1`
- 인터프리터: `/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux-wt/pr-43/.venv/bin/python`
검증 트리의 HEAD/tree와 clean 상태를 확인했다. 프로젝트 코드, 브랜치, 실제 세션은 변경하지 않았다. 실제 `.mam.env`를 읽거나 export하지 않았다. 테스트 및 탐침의 설정·레지스트리·HOME·산출물은 임시 디렉터리를 사용했다. 기준 비교는 `git archive <base>`로 만든 `/tmp/3001f1d3-base` 사본의 purge 테스트만 같은 인터프리터와 검증 cwd에서 실행했다.
## 수정 필요 사항
### R1 — [P2] 설정 파일을 읽을 수 없는데 래퍼가 성공으로 계속 진행한다
**근거:** `.agents/skills/lib_py/delegation/envfile.py:52`는 `OSError`와 `UnicodeDecodeError`를 빈 설정과 경고로 바꾸고, `:94`에서 이를 받은 CLI는 `:99`에서 항상 0을 반환한다. 래퍼 `.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job:42`의 실패 검사는 따라서 이 오류를 잡지 못한다.
**재현:** 임시 파일에 `PROBE_A=one`을 쓰고 `chmod 000`한다. 해당 파일을 `MAM_ENV_FILE`로 지정하고 실제 래퍼의 `--help`를 실행한다. HOME/WORKSPACE_ROOT와 로그·레지스트리도 임시 경로로 지정한다. 확인 후 파일 권한을 복원한다.
**기대:** 파일 전체 읽기 실패는 명시적 오류와 nonzero 종료. 잘못된 개별 줄을 경고 후 건너뛰는 규칙과 구분해야 한다.
**실제:** head는 `cannot read (PermissionError)` 경고와 함께 **rc 0**, usage 출력을 반환했다. 같은 파일과 환경의 base 래퍼는 **rc 1 / Permission denied**였다. 잘못된 UTF-8 파일도 head에서 경고와 rc 0이었다. 실제 submit에서는 필요한 설정 없이 기본 설정으로 진행할 수 있다. 기존 `test_a1_an_unreadable_parser_fails_loudly`는 이름과 달리 파일 읽기 실패가 아니라 Python 실행파일 부재만 시험한다(`tests/test_issue26_envfile.py:138`).
**수정 방향:** 공통 파서에서 전체 읽기/디코딩 실패를 개별 줄 경고와 별도로 표현하고 CLI 및 Python 진입점으로 실패를 전달한다. 기존의 선택적 파일 부재 처리와 개별 잘못된 줄 건너뛰기는 유지한다. 실제 권한 오류 및 invalid UTF-8에 대한 wrapper rc 회귀 검증을 추가한다.
### R2 — [P2] 로케일에 따라 Bash와 Python이 같은 정수 입력을 다르게 해석한다
**근거:** `.agents/skills/lib.sh:2346`은 로케일 의존 `[[:space:]]`를 사용한다. `.agents/skills/lib_py/loop/runner.py:74`는 ASCII 공백 6개만 제거하고 `:75`에서 ASCII 숫자만 허용한다. `runner.py:483`은 이 결과를 명시적인 `--timeout`으로 전달한다.
**재현:** `LC_ALL=en_US.UTF-8`, `MAM_JOB_TIMEOUT_S='\u200330\u2003'`를 실제 U+2003 문자로 설정한다. 임시 HOME/WORKSPACE_ROOT, 없는 MAM_ENV_FILE로 (a) 실제 wrapper `submit --agent human --dry-run`의 registry `timeout_sec`, (b) 실제 `LoopRunner(...).parse_args()` 후 `job_budget`/`contract_args()`를 비교한다. 재현 코드: `/tmp/3001f1d3-probe2.py`.
**기대:** 동일 입력은 두 경로에서 같은 예산 또는 같은 기본값으로 정규화된다.
**실제:** 직접 wrapper는 **30초**로 등록했다. runner는 invalid 경고 후 **3600초**, `['--timeout', '3600']`을 선택했다. U+00A0 및 U+3000도 helper 비교에서 같은 불일치를 보였다. `LC_ALL=C`에서는 Bash도 거부했다. 정상 ASCII 공백과 `0700`은 C/UTF-8 모두 정상이다. 현재 표 기반 테스트는 유니코드 공백과 로케일을 포함하지 않는다.
**수정 방향:** 문자 집합을 명시하고 로케일에 독립적인 한 규칙으로 통일한다. 브리프의 Python 판정 원칙을 따르면 공통 Python 정규화기를 Bash가 호출하는 방식이 적합하다. Bash 구현을 유지해야 한다면 최소한 함수 내부 C 로케일/명시적 ASCII 공백 등으로 범위를 고정하고 두 로케일에서 실제 wrapper와 runner를 비교한다. C 로케일에서 위 불일치가 없어지는 것은 별도 탐침으로 확인했다.
### R3 — [P2] 명시적으로 전달한 빈 정수 플래그가 검증을 우회한다
**근거:** `.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job:197`의 `[ -n "${!_var}" ] || continue`는 미지정 optional 값과 사용자가 전달한 빈 값을 구분하지 않는다. `:201`은 빈 idle timeout을 기본값으로 다시 만든다.
**재현:** 격리된 임시 환경에서 실제 래퍼를 `submit --agent human --dry-run --prompt probe --registry-dir <tmp> --idle-timeout ''`로 실행한다. 다른 네 플래그도 `MAM_DELEGATE_JOB_NO_MAIN=1`로 실제 래퍼를 source한 뒤 `parse_opts <flag> ''`로 확인했다.
**기대:** 명시적 빈 문자열은 양의 10진수 플래그가 아니므로 등록 전에 rc 1과 해당 플래그 오류 메시지.
**실제:** `--idle-timeout ''`은 **rc 0**으로 잡을 등록하고 `idle_timeout_sec=120`을 기록했다. 다섯 플래그 모두 `parse_opts`에서 수락된다. `--heartbeat ''` 또는 `--heartbeat-misses ''`는 그 단계에서 파생 idle timeout을 0으로 만든다. 후속 argparse가 일부 빈 값을 거부하더라도 새 검증의 일관된 rc 1 계약을 충족하지 못한다.
**수정 방향:** 명시적 플래그 값은 소비 시 검증하거나 presence를 기록하여 빈 값도 검증한다. 미지정 `--idle-timeout`에만 파생 기본값을 적용한다. 다섯 플래그의 빈 문자열을 현재 invalid-value 표에 추가하고 잡 미등록까지 검증한다.
### R4 — [P2] NUL이 들어간 값은 Python 로더를 종료시키고 Bash에서는 다른 값이 된다
**근거:** `.agents/skills/lib_py/delegation/envfile.py:69`–`:72`는 NUL을 값으로 수락한다. `:82`의 실제 `os.environ` 대입은 `ValueError`를 발생시키지만 `:94`의 일반 dict는 수락하며 `:98`에서 그대로 출력한다. `mqtt_common.py:100`에는 이 예외 처리가 없다.
**재현:** 임시 파일을 정확히 `b'GOOD=ok\nVALUE=left\x00right\nAFTER=yes\n'` 바이트로 쓰고 같은 파일을 두 진입점에 지정한다. 실제 wrapper를 source한 후 세 환경값을 출력하고, 별도 Python 프로세스에서 `mqtt_common.ensure_env_loaded()`를 호출한다. 재현 코드: `/tmp/3001f1d3-probe.py`.
**기대:** OS 환경변수로 표현할 수 없는 값은 공통 파서에서 값 없는 줄 번호 경고와 함께 거부되거나, 두 진입점 모두 같은 명시적 실패를 반환한다.
**실제:** wrapper는 **rc 0**, `VALUE=leftright`, `AFTER=yes`. Python은 **rc 1**, `ValueError: embedded null byte`로 종료하여 뒤의 설정을 적용하지 못한다. 현재 “하나의 입력 → 같은 값/경고” 계약 밖의 차이다.
**수정 방향:** 공통 파서에서 NUL을 거부한다. 값이나 전체 원문을 경고에 포함하지 않는다. 정상 앞뒤 줄 처리와 두 실제 진입점의 동일 동작을 회귀 검증한다.
## 브리프의 7개 검토 항목
1. **파서 규칙:** `envfile.py:50`–`:83`이 양쪽의 공통 구현이다. 키/값 바깥 공백 제거, 한 쌍의 따옴표 제거, 값 내부 `=`, 빈 값, `export KEY=` 거부, 잘못된 키/줄 경고, 중복 키의 첫 값 우선은 코드와 기존 테스트에서 일치한다. CRLF는 두 줄을 정상 파싱했고 BOM은 첫 키를 invalid key로 건너뛰었다(탐침 확인). BOM 지원을 약속한 문서는 없으므로 이것 자체를 결함으로 판정하지 않았다. R4의 표현 불가능한 값은 불일치한다. `.mam.env.example:32`, `:36`, `:49`, `:110`의 경로 예시는 확장 없는 절대경로로 바뀌어 파서와 맞는다. 다만 템플릿 헤더 `:10`에는 env 우선, shell expansion 없음, `export`/inline comment/중복 키/BOM 규칙 설명이 없다. 사용자 문서에 문법과 이전 source 방식에서의 이행 예를 추가하는 것을 권고한다.
2. **eval 경로:** `envfile.py:21`, `:63`, `:66`, `:98`의 키 fullmatch/예약 변수 차단/shlex.quote가 일반 문자열의 명령 치환과 따옴표 탈출을 막는다. wrapper `:42`는 자신의 세 경로 변수를 parser env에 넣어 파일로 교체되지 않게 한다. 일반 값과 예약 변수 공격 테스트에 우회는 관찰하지 않았다. `-I`는 stdlib-only 파일을 절대 경로로 실행하므로 이 파서에는 PYTHONPATH 불필요하며 격리가 적절하다. Python 실행 불가 시 wrapper rc 1+ERROR는 테스트로 검증된다. 파일 읽기 실패는 R1, NUL은 R4와 같이 별도 수정이 필요하다.
3. **우선순위 변경:** `envfile.py:81`의 key 존재 검사로 기존 env가 빈 문자열이어도 파일을 이긴다. 이전 wrapper source는 파일이 덮어썼으므로 의도적인 호환성 변경이다. runner의 env 계약(`runner.py:477`) 및 creator에 전달되는 control-plane 네 env(`lib_py/control_plane.py:24`, `:70`; create script `:230`, `:242`)를 작업 트리 파일이 덮어쓰지 않게 된다는 이점이 있다. 반대로 기존 pane의 broker/budget env가 오래됐으면 파일 수정만으로 바뀌지 않으므로 unset 또는 새 환경이 필요하다. Python remote 진입점(`remote.py:463`)은 이전에도 env 우선이었다. 테스트 env/MAM_ENV_FILE 격리와도 맞는다. 호환성 변경은 코드 주석과 테스트(`test_issue26_envfile.py:184`)에는 있지만 사용자용 템플릿/README에는 설명이 추가되지 않았다.
4. **정수 노브:** `tests/test_issue26_ints.py:35`의 표에 해당하는 shell arithmetic 입력은 공통 Bash helper를 통과한다. 예산/heartbeat/misses/ack 및 명시적 timeout 플래그의 min=1, inject/onboard/SKS 대기의 min=0은 기존 의미와 맞는다. 유효 9자리 경계와 선행 0 제거는 큰 입력을 int/arithmetic 전에 제한한다. 다만 R2/R3 때문에 “같은 규칙”은 아직 성립하지 않는다. PR 범위 밖의 기존 Python 노브(`runner.py:580` retry, `:628` wait grace, `:749` child wait, `:752` tree max)와 loop-count CLI(`:208`, `:223`, `:235`)는 이 helper를 사용하지 않는다. 따라서 모든 정수 설정이 통일됐다고 문서화하면 안 된다. 이들은 기존 코드이며 이번 변경의 신규 회귀로 별도 반려하지 않는다.
5. **로컬 MQTT:** `tests/_local_mqtt.py:90`의 localhost port 0 bind가 포트 배정을 OS에 맡긴다. `:180` 이후 실제 CONNECT/CONNACK, QoS 0/1, `+`/`#`, SUBACK, retained 저장/재전달/빈 payload 삭제, UNSUBACK, PING을 구현한다. PUBACK 전에 route하는 순서와 실제 paho 응답 대기 테스트(`test_local_mqtt.py:100`, `:140`, `:173`)가 wire 동작을 검증한다. 변경된 기존 테스트에서 의미 있는 단언 삭제는 찾지 못했다. 외부 브로커 실패 skip을 실패로 바꾸고 remote/heartbeat 수신 감사 로그 단언을 추가했다(`test_issue18_liveness_contract.py:1098`, `:1228`; `test_issue19_subscriber_daemon_lifecycle.py:422`). static scan은 `tests/*.py` 범위의 재유입 방지이지 모든 가능한 외부 네트워크를 차단하는 sandbox는 아니다. session fixture는 env를 복원하고 broker를 stop한다(`conftest.py:1569`); 이 호스트의 별도 start/stop 탐침에서 0.4초 후 broker thread 잔류는 없었다. 구현은 daemon thread를 join하지 않으므로 모든 플랫폼/종료 경합에 대한 누수 부재까지 증명한 것은 아니다. TLS/auth/persistent session/QoS2 등은 명시적 비지원 범위다.
6. **기준 실패 주장:** 이 호스트의 head와 base에서 `tests/test_issue10_13_purge.py`는 각각 **23 passed**, 실패 0건이었다. base 파일과 테스트 fixture를 archive 사본에서 실행했다. UUID4 및 임시 HOME/registry 격리를 먼저 확인했다(`test_issue10_13_purge.py:51`, `:138`; `conftest.py:193`). 따라서 “13개 실패 중 12개가 이 호스트의 기준 purge 실패”는 이번 검증으로 뒷받침되지 않는다. 과거 실행의 환경 차이/로그 없이는 그 당시 결과를 부정하거나 나머지 1건 원인을 단정할 수도 없다. PR 본문에 현재 재현 결과 또는 당시 명령·환경 차이를 명시해야 한다.
7. **설계 경계 #17/#42:** 파일 문법 판정의 Python 단일화는 목표와 맞는다. 정수 부분은 `lib.sh:2345`/`:2359`에 새로운 수락/거부/기본값/경고 판정을 두고 `runner.py:71`에 별도 구현을 복제했다. 따라서 브리프의 “판정은 Python” 경계에 완전히 부합하지 않으며 R2가 실제 drift 증거다. 공통 Python 규칙으로 정리하거나 제한적 Bash 예외를 명시적으로 합의해야 한다. 이 평가는 브리프가 제시한 합의와 저장소 설계 문서(`docs/design/worktree-creator-model.md:116`)를 기준으로 하며, 원격 이슈 댓글 원문까지 독립 확인했다고 주장하지 않는다.
## 검증 결과와 재현 산출물
- 브리프 지정 7개 모듈: **228 passed, 1 skipped in 527.11s**, rc 0. 유일한 skip은 macOS에서 `/proc` 기반 process-tree 테스트를 제외하는 `tests/test_issue30_stall_watch.py:40` 조건이다.
- 추가 변경 모듈(`test_issue19_subscriber_daemon_lifecycle.py`, `test_issue6_fail_closed.py`, `test_b19_headless_reconcile_fixes.py`): **61 passed in 173.09s**, rc 0.
- purge 비교: head **23 passed in 11.24s**, base **23 passed in 9.96s**, 모두 rc 0.
- `git diff --check <base> HEAD`, 두 변경 shell 파일의 `bash -n`, 변경 Python 파일의 AST syntax 검사: 모두 통과.
- 테스트 종료 후 검증 트리 `git status --short`와 `git diff --quiet`: clean.
지정 테스트 명령(위 검증 cwd):
```bash
AGENT_PYTHON_BIN=/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux-wt/pr-43/.venv/bin/python \
PYTHONDONTWRITEBYTECODE=1 \
/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux-wt/pr-43/.venv/bin/python \
-m pytest -p no:cacheprovider -q --basetemp=/tmp/3001f1d3-selected \
tests/test_issue26_envfile.py tests/test_issue26_ints.py tests/test_local_mqtt.py \
tests/test_issue18_liveness_contract.py tests/test_issue33_budget.py \
tests/test_issue30_stall_watch.py tests/test_tier1_unit.py
```
로그: `/tmp/3001f1d3-selected.log`, `/tmp/3001f1d3-migrated.log`. 추가 모듈도 같은 환경/옵션에 별도 `--basetemp=/tmp/3001f1d3-migrated`를 사용했다. 실제 `.mam.env`는 export하지 않았다.
추가 탐침 로그:
- `/tmp/3001f1d3-probe.log`: 빈 플래그, NUL, CRLF/BOM, invalid UTF-8, Unicode whitespace, broker stop.
- `/tmp/3001f1d3-probe2.log`: 실제 dry-run wrapper와 runner의 예산 차이, 실제 빈 idle-timeout 등록, PermissionError.
- `/tmp/3001f1d3-purge-head.log`: 23 passed in 11.24s.
- `/tmp/3001f1d3-purge-base.log`: 23 passed in 9.96s.
탐침 재실행은 위 검증 cwd에서 `PYTHONDONTWRITEBYTECODE=1 <위 인터프리터> /tmp/3001f1d3-probe.py "$PWD"` 및 `probe2.py`로 가능하다. 모두 자체 임시 디렉터리와 합성 설정만 사용한다. 전체 약 75분 스위트는 지시대로 실행하지 않았다.
[VERDICT: NOT PASS]
@@ -0,0 +1,19 @@
# PR #62 / Issue #60 Linux follow-up review
**Scope.** Reviewed base `1d7b7dab58a10fe86fd747b7290d47674ca93230` through head `6a4e385a78c8a561d5cf3644336de6a352ea9271` in the assigned review-only worktree. HEAD and tree matched the brief; the worktree was clean. No source files, branches, services, or user data were changed.
**NOT PASS findings**
1. **`auth_ok()` accepts a non-executable home install.** At `.agents/skills/lib_py/agents/adapters/opencode.py:317-321`, the `PATH` candidate is checked by `shutil.which`, but the home candidate is accepted by `os.path.exists` alone. Reproduction: with a temporary HOME containing `.opencode/bin/opencode` mode `0644`, an empty PATH, and no XDG variables, `adapter.auth_ok()` returned `True`. `create_session.sh` now relies on this result as its preflight, so it can report the installation as functional and proceed although the CLI cannot be executed. Check that the home candidate is a regular executable file (e.g. `os.path.isfile` and `os.access(..., os.X_OK)`), and cover a non-executable file and directory in the auth tests.
2. **The placeholder regex can discard a real draft.** `.agents/skills/lib_py/agents/adapters/opencode.py:76-79` makes the example suffix optional, so the pattern matches the exact user text `Ask anything…`. Replaced the captured prompt line in the 2.x home fixture with that text and `extract_input_region()` returned `''`. Thus the existing input-empty signal loses this exact draft and can treat a non-empty prompt as idle. The observed 2.x placeholder fixtures include an opening quote and example, including the truncated narrow capture; constrain the pattern to that observed shape and add the exact-short-text draft as a regression case. Other tested similar strings are retained.
3. **Five newly added Mac tests fail before exercising `_ro_connect`.** In `tests/test_issue60_linux_fixes.py:153-155`, the `clean` fixture closes a WAL-mode connection and assumes macOS SQLite removes both sidecars. On this Mac the files remain after close, so `make("clean")` returns `opencode.db`, `opencode.db-shm`, and `opencode.db-wal` and the assertion at `:181` fails before the query runs. This affects the five `_descendants`, `_has_session`, `discover`, `residual_artifacts`, and `verify_artifact` parameterizations. Normalize the fixture to a genuine no-WAL state after closing/checkpointing the writer (or construct the clean database in rollback-journal mode) before asserting the directory contents.
**Validation.** On macOS / Python 3.9.6, ran the focused suite with temporary HOME/TMPDIR, XDG variables unset, the required environment scrub, `PYTHONDONTWRITEBYTECODE=1`, `-p no:cacheprovider`, and an external temporary basetemp:
`tests/test_issue60_linux_fixes.py tests/test_a4_adapter_contract.py tests/test_issue31_purge_residual.py tests/test_issue50_dispatch.py`
Result: **189 passed, 5 failed** (136.27s). All five failures share the clean-fixture setup issue above; the other WAL states and remaining focused tests passed. A separate no-WAL temporary DB probe exercised `verify_artifact`, `discover`, `_has_session`, `_descendants`, and `residual_artifacts`; each returned the expected result and left only `opencode.db`. The non-executable-file probe reproduced finding 1; the captured-pane probe reproduced finding 2. `git diff --check` and `bash -n` on the changed create/stop scripts passed. No actual OpenCode service or real data was accessed. Linux was not independently rerun in this turn.
[VERDICT: NOT PASS]
@@ -0,0 +1,14 @@
{
"loop_id": "loop-20261008T234013Z-4f5d21",
"base_sha": "97c2946ee0e11eabcecafe6407755c33c0289218",
"head_sha": "a93c9bf29279f65ce0b9c9fb2c1aeea90a659420",
"tree_sha": "741c19ba4c17601768b06684d5843d218a72beb1",
"review_commit": "a93c9bf29279f65ce0b9c9fb2c1aeea90a659420",
"reviewer": "planner-reviewer-codex-01",
"job_id": "527f1409",
"verdict": "PASS",
"transport": "herdr",
"evidence": "review-tree",
"isolation": "worktree",
"verified_at": "2026-10-09T00:45:03Z"
}
@@ -0,0 +1,126 @@
# Review — 527f1409
## 판정
PASS. 누적 변경에서 차단할 결함을 찾지 못했다. O3의 두 실제 run_loop 실행이 테스트 tmp_path 아래의 marker를 사용하고, 기존 잠금 경합/생성/해제 테스트의 marker는 유지된다. 새 회귀 테스트는 수정 전의 빈 `.mam/` 생성도 탐지한다.
## Review identity
- loop: `loop-20261008T234013Z-4f5d21`
- base: `97c2946ee0e11eabcecafe6407755c33c0289218`
- head: `a93c9bf29279f65ce0b9c9fb2c1aeea90a659420`
- tree: `741c19ba4c17601768b06684d5843d218a72beb1`
- 검증 cwd: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261008T234013Z-4f5d21/1`
- Python: `/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux-wt/loop-marker/.venv/bin/python` — 3.9.6.
- Bash: GNU bash 3.2.57(1)-release, arm64-apple-darwin25.
identity를 확인했고 git status는 깨끗하다. `git diff --check 97c2946 HEAD` 및 변경 Python 파일 3개의 AST 파싱이 통과했다. `.agents/skills`에는 변경이 없다. creator 소스 작업 트리에서 실행/수정하지 않았고 검증 트리의 코드도 수정하지 않았다.
## 변경 검토
- `tests/test_o3_scoped_guard.py:391`: reexec argv 테스트의 subprocess 환경에 tmp_path/loop-guard-active를 명시한다. 기존 환경은 dict(os.environ)으로 보존하므로 marker만 바뀐다.
- `tests/test_o3_scoped_guard.py:470`: no-freeze 테스트에도 같은 방식으로 임시 marker를 지정한다.
- 수정 대상이 한 모듈의 두 호출이므로 공통 fixture나 전역 autouse 없이 직접 지정한 범위가 적절하다. 다른 O2/O3 테스트가 의도적으로 만든 marker 경로는 건드리지 않는다.
- `tests/test_loop_marker_isolation.py:34`: checkout 복사본에서 실제 관련 테스트들을 nested pytest로 실행한 뒤 `.mam` 디렉터리 자체가 없음을 검사한다. 실행 실패나 skip을 성공으로 취급하지 않는다.
- `tests/test_issue17_workspace_root_isolation.py:71`: 이전의 하위 항목 목록 검사에 디렉터리 부재 검사를 추가한다. 잠금을 해제하면서 marker만 삭제하고 빈 `.mam/`을 남기는 경우까지 잡는다.
## 실제 실행 결과
이전 작업의 샌드박스 제한은 현재 해소되어, 이번에는 **정상 conftest와 실제 로컬 MQTT broker를 그대로 사용했다**. fixture를 제외하거나 ps를 대체하지 않았다.
실행 전 HERDR_*, MAM_*, MQTT_*, SKS_*, WORKSPACE_ROOT 및 상속된 control-plane 경로 환경을 제거했다. HOME은 새 임시 경로로 지정했다. `.mam.env`를 export하거나 내용을 출력하지 않았다. bytecode/cache 쓰기는 비활성화하고 basetemp/로그는 검증 트리 밖에 두었다.
실행 파일(모두 전체 파일):
- `tests/test_o2_race_free_lock.py`
- `tests/test_o3_scoped_guard.py`
- `tests/test_issue17_workspace_root_isolation.py`
- `tests/test_loop_marker_isolation.py`
따라서 변경한 테스트 파일 전부와 브리프의 필수 동반 파일이 포함된다. W1과 L1의 nested pytest도 실제로 완료되었다.
| 조건 | 결과 | 외부 sentinel | 검증 checkout `.mam/` |
|---|---|---|---|
| WORKSPACE_ROOT unset | 57 passed, 47.85s | 0 entries | 실행 전후 없음 |
| WORKSPACE_ROOT=기존 빈 sentinel | 57 passed, 48.29s | 0 entries | 실행 전후 없음 |
O2의 live-holder 거부, 경합 단일 승자, stale 회수, marker 보존, O3의 잠금 생성/해제 및 guard 동작이 통과했다. Python 3.9 및 macOS 기본 Bash 3.2에서 검증했다.
재현 드라이버/로그:
- `/tmp/527f1409-run.py`
- `/tmp/527f1409-unset.log`
- `/tmp/527f1409-sentinel.log`
드라이버는 환경을 정리한 뒤 해당 검증 cwd에서 다음 형태로 실행한다:
```text
<loop-marker venv>/bin/python -m pytest -p no:cacheprovider --basetemp=<외부 임시 경로>/bt -q --tb=short <위 4개 파일>
```
임시 실행 root:
- unset: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/527f1409-unset-77llo387`
- sentinel: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/527f1409-sentinel-p9dxhcge`
각 root의 sentinel 디렉터리가 비어 있음을 실행 후 확인했다.
## 수정 전 실패 확인
`/tmp/527f1409-negative.py`에서 **동일한 새 L1 회귀 함수**를 실행하되, `_copy_checkout`가 만든 외부 임시 복사본의 O3 테스트 파일만 base `97c2946` 버전으로 교체했다. 제품 코드는 base와 head가 동일하다. 원본 검증 트리나 creator 소스는 수정하지 않았다.
절차:
1. 현재 checkout을 임시 디렉터리 아래에 복사한다.
2. 그 복사본의 `tests/test_o3_scoped_guard.py`만 `git show 97c2946:tests/test_o3_scoped_guard.py` 내용으로 바꾼다.
3. 새 L1 함수가 선택한 실제 테스트들을 그대로 실행한다.
4. nested pytest의 성공/통과 수 검사 이후, 최종 `.mam` 부재 assertion에서 실패하는지 확인한다.
실제 결과:
```text
NEGATIVE_ASSERTION a test took the checkout's loop lock (its .mam was created)
COPY_MAM_EXISTS True
COPY_MAM_ENTRIES []
```
즉 테스트 본체가 성공해도 잠금 디렉터리가 남는 기존 결함을 정확히 재현했다. 현재 head에서는 같은 L1이 두 환경 모두 통과한다. 단순 테스트 실행 실패를 negative control 성공으로 센 것이 아니다.
- 로그: `/tmp/527f1409-negative.log`
- negative root: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/527f1409-negative-x1lkngmg`
## 실행 지점 조사 및 제외 근거
직접 `rg`로 run_loop.sh, RUN_LOOP, MAM_LOOP_MARKER, MAM_REAL_ROOT, runner 진입을 교차 검색하고 호출 인자/환경 및 제품 코드의 잠금 시점을 확인했다. 검색 기록은 `/tmp/527f1409-runner-audit.txt`에 있다.
| 후보 파일:라인 | 판정/제외 근거 |
|---|---|
| test_o3_scoped_guard.py:388,472 | checkout 스크립트로 실제 잠금까지 도달. 이번에 tmp marker 지정. |
| test_o3_scoped_guard.py:422 | --help로 잠금 전에 종료. |
| test_o3_scoped_guard.py:164,437 | 추출 함수/잠금 harness는 tmp_path를 REPO_ROOT 또는 MAM_REAL_ROOT/marker로 지정한다. 기존 marker 동작 검사 유지. |
| test_o2_race_free_lock.py:203 | 이미 tmp_path의 경합 marker를 MAM_LOOP_MARKER로 전달. 다른 호출은 loop_lock.sh 자체 또는 bash -n 검사다. |
| test_issue5_phase3_loop.py:163,181 | Python/legacy 두 실제 실행 모두 이미 tmp marker 지정. :151은 --help. |
| test_loop_cli.py:53,98 | session-check 실행은 tmp marker, planner 실행은 `_loop_env`의 tmp marker 및 임시 control root를 사용. 나머지 오류/도움말은 잠금 전 종료. |
| test_issue25_plan_reuse.py:355,365 | 도움말 또는 legacy 옵션 거부. 후자는 이미 tmp marker 지정, marker가 생기지 않음도 검사. |
| test_issue21_worktree_prereqs.py:398 | legacy 옵션 거부 실행에 이미 tmp marker 지정. Python runner 검사들은 임시 control root/fixture 사용. |
| test_issue21_loop_b.py:234 | checkout의 shell 호출은 임시 WORKSPACE_ROOT와 MAM_LOOP_MARKER를 명시. |
| test_issue21_acceptance.py:275,297 | 복사된 임시 main 트리의 shell 실행이며 경합용 임시 marker를 명시. |
| test_issue36_prompt_transport.py:532,539 | 임시 ctl의 복사된 스크립트와 ctl 아래 marker를 사용. :630의 추출 harness도 자기 PWD를 root로 지정. |
| test_issue5_phase4_runtime.py:142,166,183 | 실제 shell 호출은 모두 --help라 잠금 전에 종료. |
| test_o1_rebuttal.py:72,76 | --help 또는 잘못된 --max-rebut 인자로 잠금 전 종료. 나머지는 함수 추출/정적 검사. |
| test_b7_diff_untracked.py:174,189 | run_loop는 텍스트 읽기 또는 bash -n. 실제 수행하는 diff_collect는 루프 잠금을 잡지 않는다. |
| test_deploy_freshness.py:473 | 텍스트에서 export 구문을 확인하는 정적 검사. |
| test_issue5_phase0_contracts.py:481 | 문서/계약 문자열 검사. |
| test_tier4_e2e.py:279,285 | mam_sandbox에 복사된 run_loop 실행이므로 잠금 root도 임시 트리다. |
| test_issue17_workspace_root_isolation.py:56 | W1은 별도 checkout 복사본에서 선택 테스트를 실행. 새 `.mam` 부재 검사로 빈 디렉터리도 탐지. |
| test_loop_marker_isolation.py:34 | L1도 별도 복사본에서 실행하고 그 복사본의 `.mam` 부재를 확인. |
직접 Python `LoopRunner.run()` 진입도 확인했다. runner.py:1241의 run/_run_body는 shell의 mam_acquire_loop_lock를 호출하지 않는다. shell 진입이 실제 잠금을 획득하고 Python의 release_guard는 현재 PID 소유 marker만 해제하는 구조다. 따라서 Python runner의 단순 인스턴스 생성/메서드 mock 검사들을 shell 잠금 획득 누락으로 분류하지 않았다.
새 L1 목록은 일부 잠금 이전 거부 경로도 보수적으로 포함한다. 명시적으로 임시 marker를 이미 쓰는 다른 후보가 L1 목록에 전부 들어가야만 격리가 성립하는 구조는 아니며, 해당 제외 사유를 위에서 확인했다. 추가로 수정해야 할 checkout 잠금 획득 지점은 발견하지 못했다.
## Lifecycle
요청된 started 명령을 먼저 실행했고 exit 0으로 완료되었다. 정상 heartbeat 절차를 사용했다. 보고서 작성 뒤 요청된 completed 명령을 실행한다.
[VERDICT: PASS]
@@ -0,0 +1,14 @@
{
"loop_id": "loop-20261003T230658Z-e7866a",
"base_sha": "8d26106195a0a24621f140e76389d206332f84c0",
"head_sha": "17eba0904ebbf54c1d2243ad61a6f2ba17263889",
"tree_sha": "a0f1ffede44be7c86267a56b25871e1d081feafb",
"review_commit": "17eba0904ebbf54c1d2243ad61a6f2ba17263889",
"reviewer": "planner-reviewer-codex-01",
"job_id": "52dc1a69",
"verdict": "PASS",
"transport": "herdr",
"evidence": "review-tree",
"isolation": "worktree",
"verified_at": "2026-10-03T23:35:18Z"
}
@@ -0,0 +1,81 @@
# PR #46 2회차 델타 리뷰 — 52dc1a69
**PASS**. 이번 한 커밋에서 이전 R1~R3가 해결됐다. 지정 테스트 결과는 아래에 기록한다. 제품 코드 변경은 없으며 새 반려 사유는 발견하지 못했다.
## 검증 identity와 범위
- loop: `loop-20261003T230658Z-e7866a`
- base: `8d26106195a0a24621f140e76389d206332f84c0`
- head: `17eba0904ebbf54c1d2243ad61a6f2ba17263889`
- tree: `a0f1ffede44be7c86267a56b25871e1d081feafb`
- 검증 cwd: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261003T230658Z-e7866a/1`
- interpreter: `/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux-wt/pr-46/.venv/bin/python`
브리프의 HEAD/tree와 clean 상태를 확인했다. `git diff 8d26106 17eba09` 한 커밋만 재검토했다. 변경은 create/resume/stop SKILL.md, JSON 목록, 문서 가드 테스트의 5개 파일(93 insertions, 19 deletions)이다. 제품 스크립트·Python·버전 변경은 없다. `git diff --check 8d26106 HEAD`도 통과했다.
## 이전 지적 판정
| 지적 | 판정 | 현재 근거 |
|---|---|---|
| R1: attach의 격리 서버 선택 유실 | **종결** | `.agents/skills/multi-agent-mux-resume/SKILL.md:30`, `:63`, `:139`가 모두 `herdr --session <server> agent attach <name>`을 안내한다. :63은 child의 export가 caller에 전파되지 않는 점과 native herdr의 환경변수 라우팅 부재를 설명한다. :106에서 row의 서버를 읽고 :109/:126의 get/read에 전달한다. |
| R2: dry-run이 아무것도 쓰지 않는다는 설명 | **종결** | resume SKILL.md:73 및 create SKILL.md:164가 spawn·registry 갱신 생략으로 범위를 좁히고 macOS quarantine 속성 제거를 명시한다. 제품의 기존 xattr 호출과 dry-run 반환 순서에 맞는다. |
| R3: inventory 갱신으로 삭제 (a) 본문 복귀를 숨길 수 있음 | **종결** | `tests/test_issue42_skill_blocks.py:137`–`:153`이 현재 문서를 직접 파싱해 삭제 sha/fingerprint와 비교한다. `DATA["blocks"]`의 class 선언에 의존하지 않는다. 아래 독립 재삽입 탐침을 거부했다. |
| C1: base create의 호출 블록 하나 미추적 | **종결** | `tests/data/skill_code_blocks.json`의 새 `superseded` 항목이 base create SKILL.md:263–266, sha `ceb96a8c1293`, class c/kind invocation, replacement를 기록한다. base 실물과 위치/hash/fingerprint를 대조했다. |
| C2: 종료 코드 스캐너의 일반적인 heredoc 한계 | **후속 유지, 비차단** | 이 커밋에서 스캐너를 바꾸지 않았다. 1회차에서도 현재 세 스크립트의 표가 틀렸다고 판정하지 않은 개선 제안이다. 이번 델타의 반려 사유로 재사용하지 않는다. |
## 독립 검증
### 서버 라우팅과 Verification 경계 사례
실제 `/opt/homebrew/bin/herdr --help`를 임시 HOME 환경에서 실행해 전역 `--session` 옵션을 확인했다(서버 접속 없음). 이후 임시 herdr argv 기록 대역을 사용했다. caller의 `HERDR_SESSION_NAME=caller-server`와 무관하게 두 경우 모두 정확히 다음 argv를 전달했다.
```text
native: --session probe-isolated agent attach probe-codex
lib.sh shim: --session probe-isolated agent attach probe-codex
```
shim 경로는 실제 lib.sh를 source하되 WORKSPACE_ROOT/HOME/registry를 모두 임시 경로로 두고 MAM_NO_AUTOSTART=1로 실행했다. 실제 herdr 서버나 agent에는 접속하지 않았다. `.agents/skills/lib.sh:255`–`:269`의 명시 `--session` 보존 분기와도 일치한다.
문서의 SERVER 대입 행을 그대로 추출해 합성 YAML로 확인했다.
| row 상태 | 실제 결과 |
|---|---|
| `herdr_session: probe-isolated` | rc 0, SERVER=probe-isolated |
| 해당 row는 있지만 `herdr_session` 없음 | rc 0, SERVER=default |
| 해당 row 없음 | 대입 rc 1, IndexError, SERVER 비어 있음 |
| legacy `herdr_server`만 존재 | rc 0, SERVER=default |
마지막 두 경우는 Verification 예제의 한계로 남는다. row가 없으면 traceback을 내며 명확히 실패하지만, 블록에 `set -e`/명시적 guard가 없어 그대로 복사 실행한 shell이 후속 조회를 시도할 수 있다. legacy alias만 있는 row는 alias를 읽지 않는다. 다만 이 블록은 **성공한 resume 뒤의 검증**이고, `update_yaml_resumed.sh:140` 및 `:154`–`:156`이 새 row와 기존 legacy row에 `herdr_session`을 채운다. 따라서 정상 resume 경로의 R1은 해결됐으며 이를 새 반려 사유로 삼지 않는다. 이 예제를 실패 복구용 범용 조회로 확대하려면 row 부재 시 즉시 중단하고 `herdr_server` alias도 고려하는 것이 좋다.
### 삭제 본문 가드와 정상 예제 허용
1. base `45d1621`에서 `removed` 다섯 개와 `superseded` 하나의 실제 본문을 읽었다. 여섯 항목 모두 base start/end, exact sha, whitespace fingerprint가 데이터와 일치했다.
2. 여섯 본문 각각을 원문 및 들여쓰기 변경 형태로 되살려 `_returned_bodies()`가 모두 검출하는 것을 확인했다.
3. 1회차와 동일하게 삭제 resume Workflow를 현재 문서 끝에 추가하고 inventory도 정확한 위치/hash 및 class c로 갱신했다. 코드/문서 파일을 수정하지 않고 모듈의 입력과 DATA에 메모리상으로만 적용했다. 기존 inventory 일치와 class 가드는 통과했지만 **새 `test_no_deleted_body_came_back()`는 예상대로 AssertionError**였다.
4. 경로·agent·role을 구체적으로 지정한 다른 정상 create 호출 예제는 검출되지 않았다. 부분 명령이나 단어가 겹친다는 이유로 거부하지 않고 전체 본문 identity를 비교한다.
`fingerprint()`(:132–134)는 whitespace를 하나로 접고 전체 본문을 해시한다. 이로써 들여쓰기/reflow로 동일 본문을 되살리는 경우를 잡는다. 의미를 분석하는 parser는 아니므로 문자열 안 whitespace까지 같게 취급하며, comment 추가 등 모든 의미적 중복을 잡는다고 보장하지 않는다. 현재 여섯 개의 제한된 삭제 identity를 보존하는 가드로는 합당하다. `superseded`의 같은 옛 호출도 되살리지 않는 정책이므로 그 예제를 의도적으로 다시 쓸 때에는 삭제 이력 정책도 함께 재검토해야 한다.
새 자체 테스트(:156–166)는 helper의 exact/fingerprint 경로를 확인한다. 이름과 달리 DATA를 실제로 갱신하는 테스트는 아니지만 helper가 DATA blocks를 읽지 않고, 이번 독립 탐침이 전체 가드의 inventory 갱신 우회를 직접 검증했다. 이 차이는 현재 동작 결함이 아니다.
### inventory·보완 문구·범위
현재 33개 블록의 `(skill, class, kind)`를 이전 JSON과 순서대로 비교해 모두 동일함을 확인했다. resume Verification의 end/sha만 바뀌었고, 기존 removed 항목에 fingerprint, 새 superseded 항목이 추가됐다. 분류 변경은 없다.
create exit 42는 `MAM_KILL_ON_READY_TIMEOUT=1`일 때 살아 있는 process도 rollback한다는 조건을 추가했고, stop exit 0의 no-op은 purge에는 적용되지 않는다고 좁혔다. 각각 `create_session.sh:392`–`:398`, `stop_session.sh:184`–`:198`과 일치한다. 이 문구 보완도 제품 동작 변경을 요구하지 않는다.
## 테스트 결과
**132 passed, 1 skipped in 296.61s (0:04:56)**, 종료 코드 0. skip은 워크트리에 untracked `.claude/skills` 링크가 없는 정상 조건이다. 기존 stop 예제 최소 3개/--agent 가드도 통과했다. 로그: `/tmp/52dc1a69-selected.log`. 테스트 종료 후 git status는 clean이며 HEAD/tree도 브리프와 그대로 일치한다.
실행 명령(위 검증 cwd):
```sh
AGENT_PYTHON_BIN=/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux-wt/pr-46/.venv/bin/python PYTHONDONTWRITEBYTECODE=1 /Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux-wt/pr-46/.venv/bin/python -m pytest -p no:cacheprovider -q --basetemp=/tmp/52dc1a69-selected tests/test_issue42_skill_blocks.py tests/test_tier2_component.py tests/test_orc_onboard.py tests/test_loop_cli.py tests/test_o1_rebuttal.py tests/test_version_consistency.py
```
독립 탐침: `/tmp/52dc1a69-probes.py`; 결과: `/tmp/52dc1a69-probes.log`. 검증 cwd에서 `PYTHONDONTWRITEBYTECODE=1 <위 Python> /tmp/52dc1a69-probes.py`로 재현한다. 임시 python3 대역은 venv 인터프리터를 exec해 PyYAML을 유지한다. 초기 대역을 단순 symlink로 만들었을 때는 venv 패키지를 찾지 못해 SERVER 검증이 실행되지 않았고, 이를 수정한 최종 실행의 네 결과만 위에 기록했다.
전체 스위트와 purge 파일은 이번 델타에서 반복하지 않았다. 제품/purge 코드가 그대로이며 1회차의 purge 23/23 통과 결과를 이번 실행 수치에 합산하지 않았다. 실제 `.mam.env`를 읽거나 export하지 않았고, 코드/검증 트리를 수정하지 않았다. 실제 `.mam` 쓰기는 지시된 잡 lifecycle 및 리포트에 한정했다.
[VERDICT: PASS]
@@ -0,0 +1,14 @@
{
"loop_id": "loop-20261018T103110Z-8a6969",
"base_sha": "74333f87d0c02888c2b5b281e912d97e74348c01",
"head_sha": "0e05b1b6777ceb837066454ac688e04a0f5d14a0",
"tree_sha": "c5cb1b16af283b3fdb447bafb865ba1abbfbb5a8",
"review_commit": "0e05b1b6777ceb837066454ac688e04a0f5d14a0",
"reviewer": "planner-reviewer-codex-01",
"job_id": "598fe58e",
"verdict": "PASS",
"transport": "herdr",
"evidence": "review-tree",
"isolation": "worktree",
"verified_at": "2026-10-18T10:34:16Z"
}
@@ -0,0 +1,17 @@
# Issue #60, review 3 (head 0e05b1b)
**Scope and identity.** Reviewed base `74333f87d0c02888c2b5b281e912d97e74348c01` through head `0e05b1b6777ceb837066454ac688e04a0f5d14a0` in the assigned review-only worktree. Its HEAD/tree matched the brief and `git status --short` was empty. No code or branch changes were made.
**Review result.** The second-round isolation defect is addressed: `tests/_agent_home_guard.py:21` now drops inherited `XDG_DATA_HOME` for each test, and `:48-49` snapshots the OpenCode directory under the ambient XDG root so data written there is still detected. `tests/test_issue60_opencode_v2.py:297-304` probes `--version` with a temporary `HOME` and `TMPDIR`; the opt-in real 2.x contract test also uses its own temporary home and standalone API processes (`:311-352`). I ran that contract test here; it passed, with no service operation or model prompt. The worktree had no changes after testing, and process inspection showed only the pre-existing user `opencode serve --service` process (PID 86524), which was left untouched.
The status change preserves the prior `yes`/`MISSING`/`no` behavior while resolving the database through the adapter and querying `session_v2`: `.agents/skills/multi-agent-mux-status/scripts/status.sh:105-121`. The same branch exists in both embedded status paths. This avoids the stale fixed `~/.local/share` lookup and does not change status formatting or exit handling. Adapter path resolution at `.agents/skills/lib_py/agents/adapters/opencode.py:90-123` applies caller XDG data only to the caller's own home, runs `debug paths` with the requested home, and falls back to that home/XDG-derived 2.x database path.
No blocking defect found in the cumulative change. The prior data-safety and purge concerns remain covered: focused tests exercise parent/child deletion, legacy rows retained by migrated databases, preservation of unrelated sessions, and residual reporting after a failed delete. `discover` selects top-level `session_v2` rows and verifies child sessions independently. The fake CLI and real 2.x opt-in test both cover the expected v2 contract. Search of active skills found no remaining OpenCode assumptions on `db path`, `FROM session`, or an OpenCode `--agent` option; only explanatory comments and generic delegation syntax matched.
**Validation.** On macOS / Python 3.9.6, ran the related suite in the review worktree with a temporary `HOME`/`TMPDIR`, XDG variables unset, requested environment scrubbed, `PYTHONDONTWRITEBYTECODE=1`, `-p no:cacheprovider`, and an external temporary basetemp:
`tests/test_issue60_opencode_v2.py tests/test_a4_adapter_contract.py tests/test_issue31_purge_residual.py tests/test_issue55_agent_homes.py tests/test_issue42_skill_blocks.py tests/test_issue10_13_purge.py tests/test_tier2_component.py::test_comp_opencode_create_and_reconcile_and_stop`
Result: **187 passed, 1 skipped** (118.14s). Also ran `git diff --check` over base..head and `bash -n` on the modified status, reconcile, create, and resume shell scripts; both passed. The full suite and Linux runtime were not rerun in this reviewer turn; the brief records the orchestrator's full-suite result (1949 passed, 56 skipped). No Linux-specific failure was observed, but this review's runtime validation was macOS-only.
[VERDICT: PASS]
Loaded 100 of 449 files, more files were not shown because too many files have changed in this diff. Show more