Compare commits

..
209 Commits
Author SHA1 Message Date
Godopu b9c828f092 merge: docs/instruction-skill-authoring (INSTRUCTION.md) 2026-10-28 00:03:23 +00:00
GodopuandClaude Opus 5.5 4490e7053b docs: INSTRUCTION.md for developing MAM itself; skills put behaviour in code before SKILL.md prose
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-10-28 09:03:14 +09:00
Godopu ad65d4833e merge: docs/issue72-admission-notes (update skill admission notes) 2026-10-27 23:52:01 +00:00
GodopuandClaude Opus 5.5 7d2f65ad42 docs(update): entries refuse during the update's checks too; create and resume take every installed workspace's lock (Issue #72 follow-up)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-10-28 08:51:54 +09:00
Godopu 225c52710a merge: fix/issue-72 (update skill, in-place install, update admission gate; resume starts the monitor; v4.12.0) 2026-10-27 22:50:04 +00:00
GodopuandClaude Opus 5.5 56e6713292 release(v4.12.0): update skill with in-place install and an update admission gate; resume starts the monitor (Issue #72) - archive the review reports, bump the lockstep version
The update skill joins the version matrix and the lockstep test's skill list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-10-28 07:49:53 +09:00
GodopuandClaude Opus 5.5 ec7f5c4e57 fix(update): the legacy start_watchdog starts the monitor in its own process group; tests clean up only the processes they started (Issue #72, review round 5)
With MAM_DELEGATION_PY=0 the monitor was left in the caller's process group
(decision 2), on every restart the update makes. The legacy body now spawns
it under job control (`set -m`, macOS has no setsid(1)) and restores the
caller's option. The update tests assert the restarted monitor's PGID in both
modes, and their teardown kills helper groups and this test's exact monitors
instead of `pkill -f <ws>`, then reports anything left that names the
workspace.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 05:18:04 +09:00
GodopuandClaude Opus 5.5 1d63e882bc fix(update): the legacy start_watchdog matches the monitor exactly too; a restart is reported only when the monitor runs (Issue #72, review round 4)
With MAM_DELEGATION_PY=0 the legacy helper still found the monitor with an
unanchored pgrep -f, so a process that only named the monitor path was
reported as the restarted monitor and none was started. lib.sh _monitor_pids
is the shell twin of daemon.find_running; update.sh and post_update.sh check
the PID start_watchdog printed with mam_monitor_started before saying
"monitor: restarted".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 04:10:15 +09:00
GodopuandClaude Opus 5.5 cf1b693e85 fix(update): the monitor is this workspace's exact command line; create/resume take the pane workspace's admission; refusal snapshots compare the registry by rows (Issue #72, review round 3)
- daemon.find_running matches `<bash> <reconcile.sh> --subscribe[ ...]`
  exactly instead of an unanchored, unescaped pgrep regex: another
  workspace's daemon (ws.a vs wsXa) or a process naming the path in an
  argument is no longer signalled by the update. update.sh re-checks which
  PIDs are still the monitor before SIGKILL.
- create and resume (spawn path) take the update admission of every
  installed workspace at or above the pane cwd, not only the control
  root's (lib.sh mam_update_admit_at, lib_py.update target-workspaces --cwd);
  the ~/.local/bin wrapper child drops all of them.
- tests: snapshot() skips the SQLite -wal/-shm and compares the DB by its
  rows (macOS keeps the sidecars and a read rewrites -shm); regression tests
  for the monitor match and for create/resume in another installed
  workspace (marker and lock wait); evidence line refs shifted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 02:28:55 +09:00
GodopuandClaude Opus 5.5 454b77215f fix(update): a state write failure before the install restarts the monitor; the exit table names the conversion code (Issue #72, review notes)
install_phase.sh exited when it could not mark the backup `installing`,
before the installer ran, without starting the monitor phase 1 had stopped
(decision 2) and leaving the backup behind. Nothing was changed at that
point, so it now removes the backup, restarts the monitor if it ran,
releases the marker and exits 1.

SKILL.md's exit row said 1 for every failure; a failed registry conversion
after a good install passes the converter's nonzero code through, and a
cancelled prompt exits 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 14:35:35 +00:00
GodopuandClaude Opus 5.5 253a0aa56e fix(delegate): admission covers a rowless target and the installed parent of an incidental .mam (Issue #72, review round 2)
R1: a target with no registry row skipped the target gate. Its cwd is now
read from the live agent on the server delivery uses (resolve_herdr_session),
through the real herdr only when that server is running, so no server is
started; a herdr that cannot answer refuses before registration.

R2: any .mam counted as a workspace and stopped the walk, so B/sub/.mam hid
an installed B and the row's control_plane root was dropped. Workspaces are
now update.sh's installed trees (.mam_deploy/remove.sh or remove.sh): every
installed ancestor of the cwd, plus the recorded control_plane root.

lib_py.update target-workspaces prints them one per line; the wrapper holds
each on fds 20.. until the record is written.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 11:09:30 +00:00
GodopuandClaude Opus 5.5 ba75718ac3 fix(update): an unlistable jobs directory refuses; the rowless-agent hint names its server (Issue #72, review R2, R3)
R2: glob() hid a listing error, so an unreadable .mam/jobs (or MAM_JOBS_DIR,
or another row's jobs dir) counted as empty and the update installed over an
open job. Records are now listed with os.listdir; a directory that cannot be
listed, or stat'ed, is a blocker with its path.

R3: a discovered agent with no row was told to run stop with --session only;
stop then looked on the workspace's server, not the one the agent was found
on. The hint now carries --herdr-session <server>, quoted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 08:15:23 +00:00
GodopuandClaude Opus 5.5 e3394655b3 fix(delegate): submit also takes the target workspace's update admission (Issue #72, review R1)
Decision 8: delegate submit uses the admission lock of the workspace the
target session belongs to. The wrapper resolved only its own
WORKSPACE_ROOT, so a job could be registered and delivered to an agent of
another installed workspace while that workspace was being updated.

lib_py.update target-workspace resolves a herdr:<name> row's workspace (the
.mam at or above its pane.cwd, else its control_plane.workspace_root);
mam_update_admit takes an fd, and submit holds the caller root on fd 7 and
the target on fd 8 until the record is written. Rowless and remote targets
keep the caller-root gate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 08:15:23 +00:00
GodopuandClaude Opus 5.5 595e34b2d1 fix(gate): no admission lock in a tree without .mam; MAM_UPDATE_MONITOR_STOP_S in the knob table (Issue #72)
mam_update_admit created .mam/ to hold its lock, so a checkout running its
own run_loop.sh got a .mam (test_loop_marker_isolation L1, test_issue17 W1).
A tree without .mam is not an installed workspace, the only kind the update
runs in, so the gate admits there without a lock. The update's monitor-stop
wait is a normalised integer knob and is listed in INT_KNOBS
(test_issue26_ints B5).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 04:48:43 +00:00
GodopuandClaude Opus 5.5 8f325de979 docs(resume): the flow ends with the monitor daemon (Issue #72)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 04:16:38 +00:00
GodopuandClaude Opus 5.5 515b3206b9 chore(shellcheck): no new findings in the update skill, lib.sh and install.sh (Issue #72)
CI now shellchecks the update skill's scripts: SC1091 is disabled at their
`source` lines as in the other skills, and the two findings this branch added
(lib.sh's loop_lock.sh source, install.sh's one-item legacy loop) are
disabled at their lines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 04:08:57 +00:00
GodopuandClaude Opus 5.5 fce9459e42 test(update): the first transition reaches the skill and the conversion (Issue #72, criterion 5)
A v4.11.0 workspace's .mam_deploy/update.sh installs the new version - the
skill present, no updater left - and the skill run after it converts the
registry. An update-backup in an unknown state now restarts the monitor it
stopped: nothing was changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 04:07:42 +00:00
GodopuandClaude Opus 5.5 47fe24e07c feat: multi-agent-mux-update skill and in-place install (Issue #72)
The update is a skill (decision 4); deploy/update.sh and the installed
.mam_deploy/update.sh are gone.

- Phase 1 (installed code, update.sh): publish the update marker under the
  exclusive admission lock; refuse before any change while a running row, a
  live herdr agent in the workspace on any running server, an open job
  (unreadable JSON included, dry_run not), a live loop marker or a
  monitor.lock held by a monitor the skills did not start (U3) is there - the
  calling agent (server + pane, its row and one running job) excepted
  (lib_py/update.py precheck, decisions 1 and 7). Stop the monitor (TERM, then
  KILL and restart it if it does not end; decision 2), handle a leftover
  backup by its state (U4), back up .agents, .mam_deploy, root files, .venv,
  the installer's .mam files and the registry (SQLite backup API under the
  registry lock) into .mam/update-backup/, then exec a copy of
  install_phase.sh outside the tree (decision 5).
- The copy (no lib.sh/lib_py, bash 3.2) runs the installer in place; on
  failure it restores the backup and restarts the monitor from the restored
  tree; on success it runs phase 2, post_update.sh of the new tree, which
  converts the registry with lib_py.registry_format and restarts the monitor
  if it ran. The backup and the marker are removed at the end.
- install.sh updates in place (decisions 3, 4, 9): install_state keys are set,
  one line each; no updater is installed and an old one (.mam_deploy/update.sh,
  or a root update.sh the manifest lists) is removed; framework files the new
  version no longer ships are deleted when unmodified, kept and listed when
  modified, and dropped from base/, the manifest and asset_hashes.txt; a local
  source's .mam is not staged. remove.sh knows the new skill; CI shellchecks
  its scripts; docs point at the skill.
- test_issue8_update_state and test_deploy_layout td6/td27 run the skill.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 04:06:57 +00:00
GodopuandClaude Opus 5.5 6359d65999 feat: update admission gate - entries refuse while an update runs (Issue #72, decision 8, U2)
The update skill publishes .mam/update-in-progress (pid + lstart, the
loop_lock.sh rules) while it holds .mam/update-admission.lock exclusively.
create, resume, run_loop.sh and delegate submit (decision 8) and stop and
orc-onboard (U2: a rollback would restore the registry without their write)
take that lock shared (lib.sh mam_update_admit, fd 7), refuse while the marker
is live or unknown, and hold the lock until their record is written. A stale
marker refuses nothing. lib_py.pane_lock acquire gains --shared and
--wait-env (MAM_UPDATE_ADMISSION_WAIT_S, default 120); create's wrapper path
closes fd 7 for the long-lived child. The exit-1 rows of create/resume/stop
name the refusal; skill_code_blocks.json pins follow the shifted lines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 04:06:42 +00:00
GodopuandClaude Opus 5.5 179473e5d5 feat(resume): resume ensures the monitor daemon (Issue #72, decision 2)
Only create started the per-workspace monitor, and only on its job path, so
the stop -> update -> resume flow the update skill requires left no monitor.
resume now calls start_watchdog with the control root as workdir (as create
does since #21 A8, also for a worktree session) after the row is written, on
both the already-running and the spawn path. MAM_NO_SUBSCRIBER_DAEMON=1 starts
none; a failure warns and does not fail the resume.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 04:06:32 +00:00
GodopuandClaude Opus 5.5 ec378e82c9 fix(attach): hints print the row's attach_command (Issue #72, comments 926/930, U1)
create's closing hint, the delegate-job wrapper's dispatch hint and stop's
stuck-TUI message printed the pre-#71 forms (`herdr session attach <name>`,
`HERDR_SESSION_NAME=... herdr agent attach <name>`), which miss an agent whose
name is over 32 characters or has upper case. They now print the session row's
attach_command, or lib_py.agents.sanitize.attach_command(server, name) when
there is no row (lib.sh session_attach_command). deploy/INSTALL.md and
install_mam.sh show the `herdr --session <server> agent attach <herdr name>`
form.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 04:05:00 +00:00
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
363 changed files with 31510 additions and 2260 deletions

No files matched your search

@@ -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,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-20261027T201245Z-63c71b",
"base_sha": "1d63e882bc1c46e9af8484647d5091b296d8fa3f",
"head_sha": "ec7f5c4e57286fb3c4238b8220f39094f4640391",
"tree_sha": "fbadb24de60ccdae684b23e2e0a41cdf23ab81eb",
"review_commit": "ec7f5c4e57286fb3c4238b8220f39094f4640391",
"reviewer": "planner-reviewer-claude-01",
"job_id": "1d2c21dc",
"verdict": "PASS",
"transport": "herdr",
"evidence": "review-tree",
"isolation": "worktree",
"verified_at": "2026-10-27T21:06:27Z"
}
@@ -0,0 +1,132 @@
# PR #75 후속 라운드 리뷰 — 1d63e88..ec7f5c4 (review round 5)
리뷰어: planner-reviewer-claude-01 (claude) · job 1d2c21dc
## 0. 리뷰 경로 확인
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261027T201245Z-63c71b/1`
- HEAD `ec7f5c4e57286fb3c4238b8220f39094f4640391`, tree `fbadb24de60ccdae684b23e2e0a41cdf23ab81eb` 로 [리뷰 identity] 와 같다.
- `git status --porcelain --ignored` 는 비어 있다. `.mam` 은 없다.
- 리뷰 전후로 확인했다. 본 체크아웃 main 은 `0e1e2e8` 그대로다.
- 범위: 1d63e88..HEAD 의 커밋 `ec7f5c4` 하나다.
- 바뀐 파일: `.agents/skills/lib.sh`(+7/−1), `tests/test_issue72_update.py`(+32/−3)
- 브리프가 정한 범위(R2 와 테스트의 `pkill` 제거) 밖은 건드리지 않았다.
## 1. R2 — legacy `start_watchdog` 의 프로세스 그룹 분리
### 1.1 코드 (`lib.sh:2297-2310`)
- 모니터를 띄우는 `nohup bash … &` 직전에 `set -m` 을 켠다.
- 켜기 전에 호출자의 `$-` 에 `m` 이 있었는지 `had_m` 에 기억한다.
- `pid=$!` 를 읽은 뒤, 원래 꺼져 있었으면 `set +m` 으로 되돌린다.
- job control 이 켜진 상태에서 띄운 백그라운드 잡은 자기 프로세스 그룹의 리더가 된다(PGID = PID). 그래서 다음 효과가 있다.
- `$!` 는 그대로 그 PID 다. nohup 과 bash 가 exec 해도 PID 는 바뀌지 않는다.
- 따라서 정확한 PID 보고(`echo "$pid"`, `:2314-2316`)와 `mam_monitor_started` 확인이 바뀌지 않는다.
- 다음 기존 동작은 바뀌지 않았다.
- 중복 방지(`_monitor_pids`, `:2291`)
- `MAM_NO_SUBSCRIBER_DAEMON=1` 분기(`:2295-2296`)
- stdin `/dev/null`, 로그 append(`>> "$log_file"`), 작업 공간 cwd, `--idle-timeout 0`
- 바뀐 것은 분기 안의 세 줄뿐이다.
- 호출하는 곳은 모두 `$(…)` 안이다. 그래서 `set -m` 은 그 서브셸 안에서만 잠깐 켜진다.
- `update.sh:106`, `post_update.sh:41`, `install_phase.sh:64`
- `create_session.sh:748`, `resume_session.sh:162`
### 1.2 이 Mac 에서 직접 확인 (`/bin/bash` 3.2, tty 없음, `set -euo pipefail`)
- 같은 모양의 함수를 스크래치 스크립트로 만들어 돌렸다.
- 결과 1: `$(f)` 안에서 띄운 자식의 PGID 는 자기 PID 와 같다(53727 = 53727). 호출자의 PGID(53714)와 다르다.
- 결과 2: 직접 호출한 경우도 마찬가지다.
- 결과 3: 호출 뒤 `$-` 는 `ehuB` 로 그대로다. 곧 `m` 이 남지 않는다.
- 결과 4: "no job control", "[1] PID", "Done" 같은 잡 메시지가 stdout 에도 stderr 에도 나오지 않았다. 그래서 `$(…)` 로 받는 PID 출력이 오염되지 않는다.
- **고치기 전 코드에서 실패하는지도 확인했다.**
- 방법: `git archive HEAD` 로 TMPDIR 에 사본을 풀었다. 그 사본의 `lib.sh` 만 1d63e88 판으로 바꿨다.
- 그 사본에서 `-k test_every_restart` 를 실행했다.
- 결과: **mode=0 의 3개(installed, killed, rolled-back)가 `left in the caller's group` 로 실패했다. mode=1 의 3개는 통과했다(3 failed, 3 passed).**
- 즉 새 assertion(`tests/test_issue72_update.py:723-724`)이 R2 를 실제로 잡는다. 수정 뒤 6개가 모두 통과한다(§3).
- 사본은 확인 뒤 지웠고, 남은 프로세스도 없다.
- 세 경로와 두 모드: 정상 설치, 정지 시간 초과 뒤 복원, rollback 뒤 복원을 mode 0·1 로 돌리는 6 조합이 모두 같은 함수를 지나고, 모두 통과한다.
- rollback 경로는 되돌린 트리의 `lib.sh` 를 쓴다. 테스트 상자에서는 그 트리가 이 버전이다.
### 1.3 python 경로와의 차이 (비차단, 참고)
- 두 경로가 분리하는 정도가 다르다.
- python 경로(`daemon.py:95`): `start_new_session=True` 로 새 **세션**을 만든다. 그래서 제어 터미널에서도 떨어진다.
- legacy 경로: 새 **프로세스 그룹**만 만든다. 세션과 제어 터미널은 호출자와 같다.
- 결정 2 의 요구("부른 쪽 프로세스 그룹에서 분리")는 이것으로 충족된다.
- 프로세스 그룹 신호(테스트 러너·timeout 래퍼의 killpg, 터미널의 SIGINT)는 이제 모니터에 닿지 않는다. 터미널 신호는 전경 그룹에만 가는데, 모니터는 배경 그룹이다.
- 터미널이 닫힐 때의 SIGHUP 은 nohup 이 무시한다.
- 배경 그룹이 tty 에 읽고 쓸 때 생기는 SIGTTIN/SIGTTOU 도 없다. stdin 은 `/dev/null` 이고 출력은 로그 파일로 간다.
- `lib.sh:246-253`(shim 서버 기동)은 같은 문제에 `start_new_session=True` 를 쓰는 관례를 적어 두었다. legacy 경로만 `set -m` 을 쓴다.
- 브리프가 "가장 작은 수정" 을 요구했고, legacy 경로는 opt-in 이므로 결함으로 보지 않는다.
## 2. 테스트의 `pkill` 제거 (`tests/test_issue72_update.py:184-194`, `:218-240`)
### 2.1 새 teardown 이 정리하는 것
- 패턴 kill 이 없어졌다. `tests/test_issue72_*.py` 전체에 `pkill`·`killall` 이 남아 있지 않다(grep 으로 확인).
- 대신 다음 순서로 정리한다.
1. 테스트가 `Box.helper`(`:132-135`, `start_new_session=True`)로 띄우고 `b.helpers` 에 기록한 프로세스의 그룹을 `killpg` 하고 `wait` 한다.
2. 이 테스트의 작업 공간 `ws` 에 대해 `bash <ws>/…/reconcile.sh --subscribe` 형태로 정확히 맞는 모니터(`_exact_monitor_pids`)와 그 자손(`_descendants`, `pgrep -P` 재귀)만 SIGKILL 한다.
- 남은 프로세스 확인: 명령줄에 `ws` 가 들어 있는 살아 있는 프로세스가 10초 안에 없어지지 않으면 이름을 적어 실패한다(`_processes_naming`, `:236-240`).
- `ws` 는 basetemp 아래의 테스트별 경로다. 그래서 다른 작업 공간의 프로세스와 겹치지 않는다.
### 2.2 남은 프로세스 확인이 약해지지 않았는지
- 예전 `pkill -KILL -f <ws>` 는 `ws` 를 명령줄에 가진 것을 **조용히 죽였을** 뿐이다. 남은 프로세스를 확인하지는 않았다.
- 새 방식은 정확히 고른 것만 죽인다. 나머지 가운데 `ws` 를 가진 프로세스가 남으면 **실패로 드러낸다**.
- 그래서 확인은 더 강해졌다.
- 대가가 하나 있다. 그런 프로세스가 생기면 정리되지 않고 남는다. 다만 이름이 실패 메시지에 나오므로 사람이 정리할 수 있다.
- `ws` 를 명령줄에 갖지 않은 손자(예: stub 모니터의 `sleep 120`)는 두 방식 모두 패턴으로는 못 찾는다.
- 새 방식은 모니터의 자손으로 찾아서 정리한다. 따라서 이 부분도 약해지지 않았다.
- 이번 실행에서 teardown 실패는 없었다. 실행 뒤 `ps` 로 봐도 테스트가 남긴 프로세스가 없다(§3).
## 3. 실행한 테스트 (이 Mac: macOS, `/bin/bash` 3.2, `/usr/bin/python3` 3.9.6)
```
cd <검증 경로>
env -u HERDR_SOCKET_PATH -u HERDR_PANE_ID -u HERDR_TAB_ID -u HERDR_WORKSPACE_ID -u HERDR_SESSION_NAME \
-u HERDR_SESSION -u WORKSPACE_ROOT -u MAM_ENV_FILE -u MAM_JOBS_DIR -u DELEGATE_JOB_LOGS_DIR \
PYTHONDONTWRITEBYTECODE=1 /Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.venv/bin/python \
-m pytest -p no:cacheprovider --basetemp="$TMPDIR/claude-j1d2c-bt" -q -rfs \
tests/test_issue72_update.py tests/test_issue19_subscriber_daemon_lifecycle.py \
tests/test_issue5_phase1_delegation.py
```
- 결과: **132 passed, 0 failed, 0 skipped** (191 s)
- `test_every_restart_starts_a_real_monitor_in_both_delegation_modes` 의 6 조합이 이 안에 들어 있다.
- `test_issue19`·`test_issue5_phase1` 의 watchdog 시험(중복 방지, `MAM_NO_SUBSCRIBER_DAEMON`, PID 보고, legacy·python 동등성)이 모두 통과한다.
- 고치기 전 대조 실행: TMPDIR 사본, `-k test_every_restart` → 3 failed(mode=0), 3 passed(mode=1). 자세한 것은 §1.2.
- 테스트 뒤 남은 프로세스: 테스트가 띄운 `reconcile.sh`, stub monitor, mock herdr, 구독자는 없다. 남은 것은 원래 있던 셋이다.
- 본 체크아웃 모니터 3714
- 이번 루프 7552
- 이번 잡을 디스패치할 때 띄운 job_subscriber 53086
## 4. 발견 사항
### 차단
- 없음.
### 비차단
- **N8 (이월, R2 에도 해당).** rollback 뒤 재기동은 결정 2 에 따라 **되돌린 옛 트리의** `start_watchdog` 을 쓴다.
- 그래서 실제로 이 PR 이전 버전으로 되돌리면, `MAM_DELEGATION_PY=0` 에서는 옛 `nohup … &` 그대로라 호출자 그룹에 남는다.
- 이 PR 이후 버전 사이의 rollback 에서는 해결된다.
- 결정 2 가 정한 방식("되돌린 트리의 함수")에서 생기는 한계라 결함으로 보지 않는다.
- **1.3 의 세션 차이.** legacy 경로는 프로세스 그룹만 분리하고, python 경로는 세션까지 분리한다. 결정 요구는 충족한다.
- N5·N6·N1·N3·N4 는 이번 범위 밖이라 그대로다.
## 5. 사용자 확인 필요
- N8: rollback 대상이 이 PR 이전 버전이고 `MAM_DELEGATION_PY=0` 인 경우를 받아들일지.
- 받아들이지 않는다면 결정 2 의 "되돌린 트리의 start_watchdog" 대신 `install_phase.sh` 복사본 쪽에서 분리해 띄우도록 결정을 바꿔야 한다.
- 이전 라운드에서 넘어온 N2, 그리고 결정 8 문구 보강 건은 그대로다.
## 6. 결론
- R2 는 정상 설치, 시간 초과 복원, rollback 복원의 세 경로와 두 모드 모두에서 해결됐다.
- 고치기 전 코드에서 mode=0 이 실패하고, 고친 뒤 6개가 모두 통과하는 것을 이 Mac 에서 확인했다.
- 분리 방식(`set -m`)은 중복 방지, opt-out, PID 보고, 로그를 바꾸지 않는다. bash 3.2 에서 잡 메시지 없이 동작한다.
- 테스트의 패턴 kill 은 기록한 PID 와 정확히 맞는 모니터·자손 정리로 바뀌었다. 남은 프로세스 확인은 오히려 강해졌다.
[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,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,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,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,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,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,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,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,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,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,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-20261027T201245Z-63c71b",
"base_sha": "1d63e882bc1c46e9af8484647d5091b296d8fa3f",
"head_sha": "ec7f5c4e57286fb3c4238b8220f39094f4640391",
"tree_sha": "fbadb24de60ccdae684b23e2e0a41cdf23ab81eb",
"review_commit": "ec7f5c4e57286fb3c4238b8220f39094f4640391",
"reviewer": "planner-reviewer-codex-01",
"job_id": "028e98cb",
"verdict": "PASS",
"transport": "herdr",
"evidence": "review-tree",
"isolation": "worktree",
"verified_at": "2026-10-27T21:06:27Z"
}
@@ -0,0 +1,98 @@
# PR #75 후속 리뷰 — job 028e98cb
## 판정과 검증 대상
이번 범위의 차단 사항을 발견하지 않았다. R2의 PGID 분리와 테스트 teardown 수정이 요구 사항을 충족한다.
- base: 1d63e882bc1c46e9af8484647d5091b296d8fa3f
- head: ec7f5c4e57286fb3c4238b8220f39094f4640391
- tree: fbadb24de60ccdae684b23e2e0a41cdf23ab81eb
- 검증 경로: /var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261027T201245Z-63c71b/1
- HEAD/tree 일치, clean status, `git diff --check 1d63e88..HEAD` 통과. 실제 diff는 lib.sh와 test_issue72_update.py 두 파일이다.
- 새 브리프의 좁혀진 범위인 1d63e88..HEAD를 검토했다. 이전 비차단 권고에 해당하는 파일/기능을 추가 변경하지 않았음을 확인했다.
- `tea issues 72 --comments`, `tea pulls 75`를 다시 읽었다. 기준 이슈 내용은 앞선 검토와 같고, 이번 수정은 결정 2 및 기존 중복 방지/opt-out/PID/로그 계약과 정합적이다.
## 확인한 것
| 항목 | 파일:줄 근거 | 확인 결과 |
|---|---|---|
| R2: 호출자 PGID에서 분리 | `.agents/skills/lib.sh:2303–2309` | 기존 monitor 옵션을 기록하고 spawn 때만 `set -m` 사용. 비동기 monitor가 별도 PGID에서 뜨며, 기존 옵션은 복원된다. macOS에 없는 setsid(1) 없이 동작. |
| 기존 기동 계약 | `lib.sh:2289–2315` | 정확한 PID 검색과 중복 방지, opt-out 분기는 유지. cwd를 작업 공간으로 두고 원래 cwd로 복원, stdin /dev/null, 로그 append, --idle-timeout 0, nohup의 SIGHUP 처리와 PID stdout 계약 유지. |
| 0/1 × 세 복원 경로 회귀 | `tests/test_issue72_update.py:699–725`, 특히 `:724` | 정상 설치·정지 timeout·rollback의 두 모드 모두 실제 새 monitor와 PGID 분리를 검증한다. decoy 미신호 및 기동 횟수 검증도 유지. 여섯 조합 모두 통과. |
| pkill 제거·소유 프로세스 정리 | `test_issue72_update.py:184–193,220–240` | 기록된 helper 그룹을 정리하고 wait한다. monitor는 이 테스트의 정확한 절대 스크립트 명령행으로 고른 뒤, 미리 수집한 자손 PID와 함께 정리한다. 전역 패턴 kill은 없다. |
| 잔여 프로세스 검출 유지 | `test_issue72_update.py:237–240` | ws를 명령행에 남긴 프로세스가 있으면 최대 10초 기다린 뒤 실패한다. 이 문자열 검색은 보고/검증용이며 kill 대상으로 사용되지 않는다. 원본 테스트를 외부 정리 플러그인 없이 실행해 통과. |
## R2의 전후 재현 결과
base 1d63e88의 부정 대조는 직전 d14ae099 리뷰에서 실제로 실행한 동일 외부 탐침 로그를 사용했다. 당시 mode=0의 정상·timeout·rollback 세 경우는 PGID가 호출자와 같아 실패했고, mode=1의 세 대조군은 통과했다. 이번에도 같은 탐침 코드를 변경 없이 지정된 새 snapshot에서 실행했다.
| 경로 | mode=0의 새 monitor PGID / caller PGID | mode=1의 새 monitor PGID / caller PGID |
|---|---|---|
| 정상 설치 | 93359 / 88049 — 분리 | 96481 / 88049 — 분리 |
| 정지 timeout 복원 | 91992 / 88631 — 분리 | 94916 / 88631 — 분리 |
| rollback 복원 | 93376 / 88631 — 분리 | 96166 / 88631 — 분리 |
- 기존 `probe_monitor_group.py`: **2 passed / 11.95s** (base에서는 1 failed, 1 passed).
- 기존 `probe_monitor_restore_group.py`: **4 passed / 10.40s** (base에서는 2 failed, 2 passed).
- 별도 직접 호출 탐침으로 monitor 옵션 초기 off→off 및 on→on, cwd 복원을 확인: **2 passed / 3.24s**. 새 monitor의 PGID 분리도 확인했다.
- PGID 검증은 조회로 했으며 호출자의 프로세스 그룹에 신호를 보내지 않았다.
base 로그와 동일 탐침 코드, 이번 새 결과를 evidence/에 보존했다. base를 이번 허용 경로 밖에서 다시 실행한 것은 아니다.
## Mac에서 실행한 테스트
/bin/bash 3.2.57, Python 3.9.6에서 실행했다. 스냅샷에 .venv가 없어 브리프 앞부분에 명시된 제어 루트 .venv/bin/python을 사용했다.
작업 디렉터리는 위 snapshot이며, 주요 실행 명령은 다음과 같다.
```bash
env -u HERDR_SOCKET_PATH -u HERDR_PANE_ID -u HERDR_TAB_ID \
-u HERDR_WORKSPACE_ID -u HERDR_SESSION_NAME -u HERDR_SESSION \
-u WORKSPACE_ROOT -u MAM_ENV_FILE -u MAM_JOBS_DIR \
-u DELEGATE_JOB_LOGS_DIR -u AGENT_SESSIONS_YAML \
PYTHONDONTWRITEBYTECODE=1 \
/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.venv/bin/python \
-m pytest -p no:cacheprovider \
--basetemp=/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review-028e98cb-pytest -ra \
tests/test_issue72_update.py \
tests/test_issue5_phase1_delegation.py::test_c07_watchdog_spawns_daemon_with_workdir_and_log \
tests/test_issue5_phase1_delegation.py::test_c08_watchdog_second_call_reuses_running_daemon \
tests/test_issue5_phase1_delegation.py::test_c09_start_watchdog_and_ensure_subscriber_daemon_agree \
tests/test_issue5_phase1_delegation.py::test_daemon_missing_monitor_raises_with_the_legacy_path \
tests/test_issue5_phase1_delegation.py::test_daemon_spawn_dedup_and_survives_parent_exit \
tests/test_issue19_subscriber_daemon_lifecycle.py::test_opt_out_still_reports_a_daemon_that_already_runs
```
**59 passed / 133.12s / exit 0**. update 파일 50건, phase1 watchdog 8건, 기존 daemon의 opt-out 보고 1건이다.
같은 환경 제거·Python·no-cache 설정으로 실행한 추가 검증:
| 선택 | 결과 |
|---|---|
| `test_issue19_subscriber_daemon_lifecycle.py::test_sandbox_default_never_spawns_a_daemon` (0/1) | 2 passed / 0.53s |
| `-p probe_monitor_group ... tests/test_issue72_update.py` | 2 passed / 11.95s |
| `-p probe_monitor_restore_group ... tests/test_issue72_update.py` | 4 passed / 10.40s |
| `-p probe_monitor_options ... tests/test_issue72_update.py` | 2 passed / 3.24s |
외부 탐침은 기존 Module의 Box fixture를 사용하고 항목만 외부 plugin으로 선택했다. source는 변경하지 않았다. 각 basetemp는 TMPDIR 아래 서로 다른 mam-review-028e98cb-{pytest,group,restore-group,options,optout}이었다. 이번에는 safe_cleanup 등의 외부 teardown 대체 없이 수정된 원본 cleanup을 실행했다.
- `/bin/bash -n .agents/skills/lib.sh`: 통과.
- `ast.parse(test_issue72_update.py, feature_version=(3,9))`: 통과.
- diff --check: 통과.
- ShellCheck는 PATH에 없어 실행하지 못했다. bash -n을 ShellCheck 통과로 표기하지 않는다.
- 이번 새 브리프가 정한 수정 파일·watchdog 범위로 실행했고 전체 스위트는 실행하지 않았다.
## 발견 사항 및 사용자 확인
차단/비차단 신규 지적 없음. R2와 pkill 제거 요구 모두 해결됐다. 새 정책 결정이나 재계획이 필요하지 않다.
## 자원 안전
- 검증은 지정된 snapshot에서만 했고 creator 작업 트리에서 실행·수정하지 않았다. installer/update는 TMPDIR 가짜 설치 및 로컬 source/file:// installer만 사용했다.
- 실제 herdr 서버·설정·에이전트 홈 DB를 조작하지 않았고, 실제 .mam.env 값을 테스트에 출력하거나 export하지 않았다. 이슈/PR 댓글·상태 변경, push/merge 없음.
- 다섯 basetemp의 종료 후 프로세스 점검 결과 `remaining_test_processes=[]`.
- 원래 제어 루트 monitor PID 3714는 실행 전후 같은 명령으로 살아 있었으며 신호를 보내지 않았다.
- 종료 시 HEAD/tree와 clean status가 그대로이며 diff --check도 다시 통과했다.
- 승인된 잡 이벤트·리포트 외에 실제 control-plane 상태를 수정하지 않았다. 원본 로그와 외부 탐침 코드는 이 리포트 옆 evidence/에 보존했다.
[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-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-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,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,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]
@@ -0,0 +1,14 @@
{
"loop_id": "loop-20261004T044643Z-1b1440",
"base_sha": "e1ed2708d835d712f5f3a9dfd51b8f66ca1ac447",
"head_sha": "150b4937f816c6407be16fc4c37ab88e478927e7",
"tree_sha": "afce44d2e0f91720d81e8d7d484f0e1358dee680",
"review_commit": "150b4937f816c6407be16fc4c37ab88e478927e7",
"reviewer": "planner-reviewer-codex-01",
"job_id": "5f1a5a29",
"verdict": "PASS",
"transport": "herdr",
"evidence": "review-tree",
"isolation": "worktree",
"verified_at": "2026-10-04T04:54:16Z"
}
@@ -0,0 +1,46 @@
# PR #47 2회차 델타 리뷰 — 5f1a5a29
**PASS**. 이전 R1(깨진 최상위 skills 링크의 skip)이 해결됐고, 실제 검사와 같은 함수를 사용하는 합성 배치 테스트가 회귀를 잡는다. 새 반려 사유는 발견하지 못했다.
## 검증 identity와 범위
- loop: `loop-20261004T044643Z-1b1440`
- base: `e1ed2708d835d712f5f3a9dfd51b8f66ca1ac447`
- head: `150b4937f816c6407be16fc4c37ab88e478927e7`
- tree: `afce44d2e0f91720d81e8d7d484f0e1358dee680`
- 검증 cwd: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261004T044643Z-1b1440/1`
HEAD/tree 일치, 시작·종료 시 git status clean. 이번 한 커밋의 변경은 `tests/test_issue42_skill_blocks.py` 하나(59 insertions, 12 deletions)이며 제품 코드·버전 변경은 없다. `git diff --check e1ed270 HEAD` 통과.
## 이전 지적 판정
| 지적 | 판정 | 근거 |
|---|---|---|
| R1: `.claude/skills` 자체가 dangling symlink일 때 skip | **종결** | `tests/test_issue42_skill_blocks.py:248`이 exists와 is_symlink가 모두 False인 경우에만 None을 반환한다. 깨진 링크는 :250–252의 symlink 검사에서 문제 목록을 반환하고, 실제 테스트 :264–267이 이를 실패로 처리한다. |
## 검증 결과
검증 cwd에서 실행:
```sh
PYTHONDONTWRITEBYTECODE=1 /Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.venv/bin/python -m pytest -p no:cacheprovider -q --basetemp=/tmp/5f1a5a29-selected tests/test_issue42_skill_blocks.py
```
**37 passed, 1 skipped in 0.06s**, rc 0. skip은 검증 트리에 원래 로컬 `.claude/skills`가 없기 때문이며, 새 합성 배치 6건은 모두 실행됐다.
- **같은 실제 경로로 전후 비교:** 검증 트리 안에 임시 `.claude/skills`를 존재하지 않는 대상의 symlink로 만들었다. base e1ed270에서 추출한 기존 함수는 같은 경로에서 SKIP했다. 현재 실제 pytest node는 **1 failed in 0.04s, rc 1**을 반환했다. 이 실패는 의도한 부정 탐침 결과이며 지정 모듈의 실패가 아니다.
- **회귀 테스트의 감도:** 현재 helper의 부재 조건만 메모리상 이전 `if not link.exists():`로 되돌린 뒤, 새 `test_claude_skills_layouts(..., "broken_top", False)`를 호출하면 AssertionError가 발생했다. 즉 새 회귀 테스트가 이전 결함을 검출한다.
- **정상 배치 유지:** 합성 `single`과 `per_skill`은 통과한다. 본 체크아웃의 스킬별 링크 배치에도 현재 helper를 읽기 전용으로 적용해 문제 목록 `[]`를 확인했다.
- **정리:** 임시 링크와 이번 탐침이 만든 부모 디렉터리를 finally에서 제거했다. 검증 트리의 로컬 배치 부재와 git clean 상태를 다시 확인했다.
독립 탐침은 `/tmp/5f1a5a29-probes.py`에 있다. 위 검증 cwd에서 `PYTHONDONTWRITEBYTECODE=1 <위 Python> /tmp/5f1a5a29-probes.py`로 재현한다. 전체 탐침 실행은 rc 0이며 base SKIP/current FAIL/조건 복원 시 합성 테스트 FAIL을 각각 확인한다. 체크아웃 소스는 변경하지 않았다.
## 합성 테스트와 범위 판단
`claude_skills_problems()`(:240–260)가 실제 로컬 배치 테스트(:264)와 합성 테스트(:294)의 공통 검사 함수다. 합성 테스트(:270–300)는 각각의 tmp_path 안에서 두 디렉터리 및 상대 symlink를 구성하고, 없음/단일 링크/개별 링크/잘못된 대상/깨진 최상위 링크/깨진 개별 링크를 직접 검사한다. 프로세스·시간·실제 사용자 배치에 의존하지 않아 결정적이다. 테스트 대상이 파일·디렉터리의 경로 해석이므로 합성 대상에 SKILL.md 본문을 만들지 않은 것도 적절하다.
기존 모듈의 8개 스킬 inventory 보장과 링크별 대상 비교는 유지됐다. 새로운 helper는 테스트 전용이며 제품 동작을 바꾸지 않는다. 기본 검사가 워크트리에서 skip되어도 합성 6건이 실행되므로 이번 수정의 핵심 조건은 더 이상 로컬 배치 유무에 가려지지 않는다.
본 체크아웃은 배치 읽기에만 사용했다. 실제 .mam.env·agent 세션·herdr 서버는 건드리지 않았고, 실제 .mam 쓰기는 지시된 lifecycle과 이 리포트에 한정했다. 재계획 승격은 필요 없다.
[VERDICT: PASS]
@@ -0,0 +1,100 @@
# Issue #60 — OpenCode 2.x Mac review
**NOT PASS.** 기본 2.x 실행·조회·삭제 계약과 테스트는 통과했다. 그러나 새 재귀 삭제 경로가 자식 세션의 구버전 데이터를 남긴 뒤 부모 purge의 residual을 빈 목록으로 보고하는 결함을 확인했다. 실제 OpenCode 2.0.32에서도 재현됐다. 국소 수정과 회귀 테스트로 해결할 수 있으므로 재계획 요청은 없다.
## Identity / 범위
- Job `614b6263`, loop `loop-20261018T055915Z-abe16e`
- Base `74333f87d0c02888c2b5b281e912d97e74348c01`
- Head `6b4b96fa1875862ef3bed830fe7b04166b1fad58`
- Tree `9cfb2106081eba96b58cc6b7573031de0acba399`
- 검증 트리 `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261018T055915Z-abe16e/1`
- macOS, 시스템 Python 3.9.6, bash 3.2, 실제 OpenCode `v2.0.32`.
시작/종료 HEAD와 tree가 일치하고 `git status --short`는 비어 있다. creator 트리 실행·수정, 제품 코드 수정, 브랜치/커밋 변경 없음. 추가 탐침과 의도적 회귀는 검증 HEAD의 임시 git archive 복사본에서만 수행했다. `tea issues 60 --login pugit --repo tmpl/multi-agent-mux --comments`로 본문과 정정 댓글도 읽었다.
## 수정 필요 — P2: 자식의 legacy 데이터가 남아도 부모 purge는 residual 없음
**파일 근거:** `.agents/skills/lib_py/agents/adapters/opencode.py:141`~`:145`는 네이티브 CLI에 부모와 자식들의 삭제를 맡기고 이를 성공으로 보고한다. 이어지는 `:158`~`:169`의 legacy SQL은 요청한 **부모 uuid 하나**만 삭제한다. `:188`~`:193`의 residual도 그 uuid 하나만 조회한다.
**재현 절차:**
1. HOME/TMPDIR가 임시이고 XDG_*가 unset인 환경에서 실제 2.0.32의 `api --standalone POST /api/session -d ...`로 부모 P, `parentID=P`인 자식 C, 무관한 세션 O를 만든다. 모델 프롬프트는 보내지 않는다.
2. 이 임시 DB에 마이그레이션 잔존 상태를 모형화하는 legacy `session(id,parent_id)`와 `message(id,session_id)` 테이블을 만들고 P/C/O에 해당하는 행을 넣는다. 실제 사용자 DB나 실제 migration 파일을 수정한 것이 아니다.
3. HEAD 어댑터로 `purge_artifacts(P, ctx)` 후 `residual_artifacts(P, ctx)`를 호출한다.
4. session_v2, legacy session/message, C의 residual을 읽는다.
**기대:** 삭제 범위를 자식까지 확장했으므로 C의 소유 legacy 데이터도 정리하거나, 최소한 남은 C 데이터를 부모 purge 결과의 residual에 포함해야 한다. 무관한 O는 유지해야 한다.
**실제:**
- session_v2에는 O만 남아 네이티브 부모/자식 삭제는 성공했다.
- legacy session에는 **C와 O**가 남았고 legacy message의 C 행도 남았다.
- purged에는 `opencode session (with its child sessions): P`, `sqlite rows for session: P`가 출력됐다.
- **부모 residual은 `[]`**였다. C를 별도로 조회해야 `session.id: 1 rows`, `message.session_id: 1 rows`가 드러났다.
- 같은 상황을 저장소 fake CLI로 시험한 추가 테스트도 실패했다. 즉 fake와 실제 CLI의 불일치가 아니라 어댑터의 자식 범위 추적 누락이다.
실제 재현 ID와 결과는 `/tmp/review-614b6263-c_7f_2xc/real-probe/result.json`, 재현 스크립트는 `real_probe.py`에 있다. 스크립트는 위 재현에 이어 독립 TUI 종료 확인까지 수행한다. `probe-copy/tests/test_issue60_opencode_v2.py::test_review_child_legacy_rows_are_not_silently_abandoned`에도 작은 fake 기반 재현이 있다.
**수정 방향:** 네이티브 삭제 전에 session_v2의 해당 부모 아래 자손 ID 집합을 확보하고, 삭제 후 legacy 정리와 residual 검증을 그 정확한 집합에 적용한다. 보존 대상 테이블의 자식 잔여물도 누락하지 않아야 한다. 실패/부분 실패 시에도 범위를 잃지 않고 WARN/residual을 남긴다. prefix나 workspace만으로 범위를 넓히면 다른 세션을 지우므로 피한다. 부모·자식·손자·무관한 세션이 있는 migrated DB 회귀 테스트를 추가한다.
## 기존 코드에서 이어지는 호환성 주의점
다음 두 가지는 추가 탐침으로 확인했지만 **이번 diff가 새로 도입한 결함으로 분류하지 않았다**. 위 자식 residual 결함만으로 NOT PASS이며, 아래 사항은 Issue #60/Linux 후속 검증에서 명시적으로 다룰 필요가 있다.
### XDG_DATA_HOME 지정 시 실제 세션 저장소를 놓침
`opencode.py:80`~`:84`에서 호출 환경의 XDG_DATA_HOME을 항상 `<ctx.home_dir>/.local/share`로 덮어쓴다. debug paths를 사용해도 이미 다른 환경으로 질의하므로 호출자의 XDG 경로를 발견하지 못한다. status.sh의 `resume_on_disk`도 기본 HOME 경로를 직접 쓴다.
임시 홈 H와 임시 `XDG_DATA_HOME=D`를 두고 fake CLI가 따르는 `D/opencode/opencode.db`에만 ses_xdg를 넣었다. 기대 discover는 `[ses_xdg]`이지만 실제 `_resolve_db`는 H/.local/share/opencode/opencode.db, discover는 `[]`였다. 이 시험에서 custom XDG를 받은 실행 파일은 **저장소 fake뿐**이며 실제 바이너리는 실행하지 않았다. 원본 이전 구현도 같은 강제 override를 하므로 신규 회귀라고 주장하지 않는다.
수정 시 대상 홈 격리 계약을 유지하면서 같은 사용자 환경의 명시적 XDG_DATA_HOME을 존중하고, spawn/discovery/status/purge가 동일한 data root를 쓰도록 해야 한다. 별도 홈을 지정할 때 ambient XDG가 실제 홈으로 새는 문제도 함께 막아야 한다. 이슈 정정 댓글의 XDG 경로 요구와 현재 동작에는 이 차이가 남아 있다.
### 공유 DB의 열린 FD가 모든 세션을 in-use로 만듦
OpenCode는 `base.py:307`~`:309`의 공통 판정을 상속하고, artifact_path가 세션 전용 파일이 아닌 전체 opencode.db다. `base.py:78`~`:83`의 lsof 결과는 어떤 프로세스든 그 DB를 열면 True다.
임시 DB의 종료된 세션에 대해 먼저 in-use=False를 확인한 뒤, UUID 인자 없이 DB 파일만 읽기 모드로 열고 잠드는 Python 프로세스를 만들었다. 같은 세션의 in-use가 True가 됐다. holder는 finally에서 kill/wait했다. 따라서 다른 standalone TUI나 사용자 서비스가 공용 DB를 열고 있으면 종료된 세션도 purge gate에서 거절될 수 있다. 실제 사용자 서비스에 대해 in-use나 DB API를 호출해 검증하지는 않았다. 공유 파일 기반 판정은 base에도 있던 동작이며 이번 변경의 신규 회귀는 아니다.
후속 수정은 단순히 안전 검사를 제거하기보다 OpenCode 세션별 보유/실행 상태 증거를 사용해야 한다. `--standalone`은 서버 생명주기를 분리하지만 DB를 세션마다 분리하는 옵션은 아니라는 점이 중요하다.
## 검증 결과
### 원본 HEAD: 181 passed, 1 skipped
실행 모듈:
- tests/test_issue60_opencode_v2.py
- tests/test_a4_adapter_contract.py
- tests/test_issue31_purge_residual.py
- tests/test_issue42_skill_blocks.py
- tests/test_issue55_agent_homes.py
- tests/test_issue10_13_purge.py
**181 passed, 1 skipped in 53.41s**, pytest exit 0. skip은 worktree에 untracked `.claude/skills`가 없어 `test_issue42_skill_blocks.py:266`을 건너뛴 것이다. 실제 OpenCode 2.x 계약 테스트는 skip되지 않고 통과했다. 전체 스위트는 오케스트레이터 범위라 실행하지 않았다.
원본 검증은 지정 venv의 `python -m pytest -p no:cacheprovider --basetemp=/tmp/review-614b6263-c_7f_2xc/pytest-head -q -ra ...`, `PYTHONDONTWRITEBYTECODE=1`로 수행했다. HERDR/MAM/WORKSPACE 바인딩을 제거하고 HOME/TMPDIR를 임시화, 실제 바이너리 계약 테스트는 XDG_DATA_HOME/STATE_HOME/CONFIG_HOME/CACHE_HOME unset으로 수행했다. Claude 관련 테스트의 실제 /tmp fallback도 격리했다.
### 추가 검증
- 추가 edge 탐침 **3 failed, 10 deselected in 2.23s**: 자식 legacy residual 누락, XDG 경로 누락, 공유 DB FD false positive. 원본 제공 스위트의 실패로 합산하지 않는다.
- discover의 SQL만 임시 복사본에서 session_v2→session으로 되돌린 대조군: **2 failed, 11 deselected in 1.30s**. 기존 `test_discover_and_verify_read_session_v2_and_skip_child_sessions`, `test_a_fresh_2x_database_has_no_session_table_and_still_works`가 빈 결과를 거부했다. 원본은 변경하지 않았고 대조군 수정도 복원했다.
- 가짜 herdr/가짜 OpenCode를 쓰는 추가 전체 연결 시험: **1 passed in 45.07s**. 실제 create_session.sh 실행 → mock DB에 parent/더 최신 child 구성 및 own ID 비우기 → reconcile.sh --once에서 drift C가 parent만 확정 → status.sh --json의 resume_state=yes → stop_session.sh로 stopped → resume_session.sh의 `--session <동일 parent> --auto --standalone` 확인. 첫 모델 메시지 대신 mock DB seed를 사용했으며 실제 프롬프트나 실제 herdr 서버는 쓰지 않았다. fixture 안의 모의 온보딩 잡만 생성됐다.
- 실제 2.0.32: 임시 홈에서 API 부모/자식 생성 및 삭제, 무관한 세션 보존 확인. 별도 PTY에서 살아남은 세션을 `--session ... --auto --standalone`으로 재개하고 `/exit` 입력 후 0.3초 간격을 두어 Enter 전송, **exit 0** 확인. 모델 프롬프트 없음.
- 시험 후 프로세스 확인: 기존 사용자 OpenCode 서비스 PID 86524만 남았다. 그 프로세스에는 제어 명령을 보내지 않았으며 시험으로 만든 OpenCode 프로세스는 남지 않았다.
- 변경 Python 파일 모두 시스템 Python 3.9 AST 검사 통과. 변경 셸 파일 모두 `/bin/bash -n` 통과. base..head `git diff --check` 통과.
## 코드/문서 검토 결론과 한계
- session_v2.directory/time_created(ms), discover의 parent_id IS NULL 필터는 새 subagent가 본 세션을 덮어쓰는 것을 피하며 새 DB와 migrated DB 가정에 맞는다.
- `debug paths`의 db 줄 파싱, `session delete --standalone -- <id>`, 실패/바이너리 부재 WARN 및 부모 residual, 없는 ID에 대한 불필요 CLI 호출 회피는 관련 테스트에서 확인됐다.
- create/resume/update_yaml의 플래그와 adapter 생성 명령은 일치한다. active `.agents/skills`, docs, README 검색에서 운영 명령으로 남은 `opencode db path`나 TUI `--agent` 사용은 찾지 못했다. 설명용 과거 동작 언급, 의도적인 legacy DELETE, `opencode run -m`은 해당하지 않는다. VERSIONS/과거 리포트는 판단에서 제외했다.
- fake CLI는 기본 db path 및 v2 재귀 삭제/legacy 보존을 실제 계약과 일치하게 모형화한다. 다만 mock lifecycle은 실제 TUI의 모든 시그널 동작을 증명하지 않는다. 실제 PTY에서는 정상 `/exit` 경로를 확인했고 실제 SIGKILL/SIGHUP 전 조합을 반복 검증하지 않았다.
- auth_ok와 auth.json 검사는 브리프의 의도된 제외 범위로 취급했다.
- Linux에서 실행한 결과는 없다. Python 3.9/bash 3.2 구문과 플랫폼 전용 새 의존성 여부를 검토했지만 Linux 런타임, 서비스/PTY 동작은 예정된 별도 검증이 필요하다.
## 증거
임시 루트 `/tmp/review-614b6263-c_7f_2xc` (실경로 `/private/tmp/...`): `head.log`, `probes.log`, `mutation.log`, `flow.log`, `real_probe.py`, `real-probe/result.json`, `real-probe/tui-output.bin`, `processes-after.json`. 추가 pytest 재현은 `probe-copy/tests/test_issue60_opencode_v2.py` 및 `probe-copy/tests/test_review60_flow.py`에 있다.
[VERDICT: NOT PASS]
@@ -0,0 +1,14 @@
{
"loop_id": "loop-20261025T023502Z-d6f876",
"base_sha": "0461a632eb667c7988fd33be92e9a4b36151f22a",
"head_sha": "bb496fde785133ad1dbec288aa7431766efb6eb5",
"tree_sha": "15a72c9a1933c64018a2f2d9aa9707348905bb4d",
"review_commit": "bb496fde785133ad1dbec288aa7431766efb6eb5",
"reviewer": "planner-reviewer-codex-01",
"job_id": "65396b40",
"verdict": "PASS",
"transport": "herdr",
"evidence": "review-tree",
"isolation": "worktree",
"verified_at": "2026-10-25T03:40:44Z"
}
@@ -0,0 +1,23 @@
# Reviewer report — PR #70 / Issue #66
Reviewed the required snapshot at `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261025T023502Z-d6f876/1`, head `bb496fde785133ad1dbec288aa7431766efb6eb5`, against base `0461a632eb667c7988fd33be92e9a4b36151f22a`. The snapshot matches expected tree `15a72c9a1933c64018a2f2d9aa9707348905bb4d` and is clean. I read Issue #66 and PR #70; the implementation matches the requested scope.
## Findings
No blocking findings.
`HerdrSource` derives `self.name` from `sanitize_herdr_agent_name(target)` while resolving the adapter from the original name (`.agents/skills/lib_py/stall_watch.py:140-164`). Its agent `get`, `read`, and `prompt` calls use the sanitized name (`182-212`); the pane operations use pane IDs, so they do not need the session name. `StallWatch` separately derives the sanitized value for `watcher.wait_once` at line 229 and calls it at line 331, while notices/logs continue to use `args.target` (for example lines 391-398). `args.herdr_session` remains the herdr server selector passed as `session=`, separate from the sanitized agent name.
The transformation in `lib_py/agents/sanitize.py` follows the same lowercase, allowed-character replacement, leading-letter prefix, 32-character cap and SHA-1 suffix rules as the active `_sanitize_herdr_agent_name` in `lib.sh:293-318`. Agent kind detection uses the original target, which is necessary for long names whose sanitized suffix is a hash. The codex long-name test verifies this using a name whose original `-codex` suffix is absent from the sanitized form (`tests/test_issue66_stall_watch_sanitized_name.py:113-128`).
The mock's `strict_agent_names` check is opt-in (`tests/conftest.py:470-477`), so its default path retains the prior matching behavior. In strict mode, the tests verify that unconverted names receive `agent_not_found` for get/read/wait/prompt and sanitized names resolve (`test_issue66_stall_watch_sanitized_name.py:52-67`). Tests also verify transformed get/read/prompt/wait calls, idle and blocked notifications, preservation of the original name in log output, and unchanged plain-name behavior (`69-110`).
## Verification
- On the head snapshot, `PYTHONDONTWRITEBYTECODE=1 .../python -m pytest -p no:cacheprovider --basetemp=/tmp/mam-review-65396b40-head-pytest tests/test_issue66_stall_watch_sanitized_name.py tests/test_issue30_stall_watch.py tests/test_issue30_stall_watch_stop.py` — **66 passed, 1 skipped** on macOS, Python 3.9.6 (357.58 s). The skip is the existing Linux-only `/proc` test.
- For the base comparison, I checked out base into `/tmp/mam-review-65396b40-base`, copied in the new regression test and its strict-mock fixture change, and ran the same test file — **5 failed, 2 passed** (82.04 s). The failures are the expected unconverted-name calls for long and uppercase targets; the plain-name and strict-mock contract tests pass.
- `git diff --check` — passed. No Bash files changed; the integration tests execute the watcher and mock herdr through the host Bash/Python environment.
- The reported `test_issue17_item4_decisions.py::test_c_finalize_ends_with_an_unreaped_wrapper[broker_unavailable-3-0]` cleanup race is unrelated to this diff: the changed paths are `stall_watch.py`, the test-only mock in `tests/conftest.py`, and the new Issue #66 regression file; no delegation or process-cleanup implementation changed.
- No real agent-home databases, actual `.mam` state, or real herdr servers were accessed.
[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-codex-01",
"job_id": "68ebbff1",
"verdict": "PASS",
"transport": "herdr",
"evidence": "review-tree",
"isolation": "worktree",
"verified_at": "2026-10-19T02:37:22Z"
}
@@ -0,0 +1,21 @@
# PR #62 / Issue #60 Linux follow-up review, round 2
**Scope.** Reviewed base `1d7b7dab58a10fe86fd747b7290d47674ca93230` through head `6a37c0019baa31ee28b32a086e57b4c8239413c4` in the assigned review-only worktree. HEAD and tree matched the brief, and `git status --short` was empty. No source changes, commits, branch operations, or real service/data access were performed.
The three findings from round 1 are addressed:
- `.agents/skills/lib_py/agents/adapters/opencode.py:76-80` now requires the quote/example portion of the observed placeholder. `tests/test_issue60_linux_fixes.py:79-91` verifies that both the bare `Ask anything…` and `Ask anything...` typed drafts are retained, while the wide and truncated-narrow UI placeholders still read as empty.
- `opencode.py:318-324` accepts the home install only when it is a regular executable file; the test at `tests/test_issue60_linux_fixes.py:334-341` rejects both a non-executable file and a directory.
- `tests/test_issue60_linux_fixes.py:156-164` removes macOS-persistent WAL sidecars only after closing the last writer, to normalize the no-WAL fixture. The clean-state queries then pass, confirming committed rows remain available after fixture setup. The other three sidecar states still exercise the intended code paths.
The follow-up also replaces direct writable SQLite opens in the two status branches (`.agents/skills/multi-agent-mux-status/scripts/status.sh:115-119,316-320`) and reconciler (`.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh:953-959`) with the adapter's `_ro_connect`, avoiding sidecar creation/removal in these readers. Their scripts receive the skill directory through the existing `env_python` bootstrap, so the new adapter import resolves in the normal execution path. The change preserves the prior status result mapping and query semantics.
No new blocking issue found. The `_DB_PATHS` cache remains scoped to binary path and effective HOME/data-home, with its test fixture clearing the cache between tests; the previously noted narrow immutable/WAL race remains a transient-read caveat rather than a new data mutation path. No live OpenCode command, service, or user database was accessed.
**Validation.** On macOS / Python 3.9.6, ran the related suite from the review worktree 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: **201 passed** (136.01s). `git diff --check` and `bash -n` on the changed status, reconcile, create, and stop scripts also passed. Linux was not independently rerun in this reviewer turn; the brief records the author's Linux run.
[VERDICT: PASS]
@@ -0,0 +1,59 @@
# Issue #17 follow-ups — Codex review
Job: 70d302c2
Loop: loop-20261007T224638Z-cf1efb
Base: 56d0bfda54e4f4fe1ccc138cc262edfb14cfb4a3
Head: 8241bd53b179cf8332541264c3bf12b8e3eca44a
Tree: efef640d36f6666f107e188d554c39f05400d4ac
Validation path: /var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261007T224638Z-cf1efb/1
## Finding R1 — subscriber-up decision failures can still report successful submission (P2)
Evidence: .agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job:384-395 (direct), :556-567 (loop/discuss); .agents/skills/lib_py/delegation/wrapper.py:332-333.
The new failure handling only protects printed answers. An execution failure in the subscriber-up predicate is consumed as a normal false answer. If the subscriber has already exited 0, the error branch maps that code to completed, finalize-begin keeps it, and _exit_for exits 0. This violates N2's requirement that a failed decision call ends the wrapper with 1, never 0.
I read the exclusion rationale in commit a670ef5: it states that subscriber-up failure treats the subscriber as dead and reaches a fail-closed sub-status/exit-code path. Those later calls can succeed and report completed, so the rationale does not cover a failure confined to the earlier process invocation. This is the same per-decision failure-injection model used by the added regression tests, not an assumption that every later Python invocation must also fail.
Reproduction (no broker or agent session):
1. Use tests/test_issue17_item4_decisions.py::_failing_py with decision="subscriber-up", mode="error" (or "gone").
2. Set SUB_STANDIN=1 and SUB_RC=0. The shim makes only job_subscriber.py return 0 and only the targeted predicate fail; other Python calls remain real.
3. Call _human_submit with a temporary HOME/workspace/registry and that shim as $PY.
4. Expected: submit exits 1 with a decision-failure diagnostic. Actual: module traceback rc 1 -> submit rc 0; missing-interpreter rc 127 -> submit rc 0. The wrapper prints "subscriber died early (... exit=0 ...)" and a log path, then returns success.
Scripts: /tmp/70d302c2-probes.py and /tmp/70d302c2-predicate-errors.py. All created job records are in temporary probe directories, not the real registry.
Fix direction: distinguish a successfully computed boolean from inability to execute the decision. A printed boolean through the existing _decided machinery (possibly a new flag/subcommand retaining the old CLI contract) can return execution status 0 for both valid answers while preserving the normal true/false branches in Bash. Treat nonzero execution status or missing/invalid output as _decision_failed. Do not simply fail on every current rc 1, since false legitimately uses that code. At minimum, rc values outside 0/1 must never be silently treated as false, but that alone cannot fix the reproduced import-error rc 1 case. Add tests for both true and false predicates, plus selectively failing subscriber-up with subscriber rc 0, in both direct and loop/discuss call sites.
The same ambiguity exists at :718 for stall-watch: a probe returning 127 yielded caller rc 0 and continued execution ("survived"), silently disabling the watcher. The creator explicitly describes the watcher as optional/notify-only; this observation is related evidence, while the submission-success counterexample above is sufficient for NOT PASS.
## Other requirements
- Restore premise: tests/test_issue45_restore.py now accepts HOME or the original removed directory. The source docstring distinguishes herdr 0.9.1/0.9.3. The additional production handling for restore_error + pane_not_found is explained by commit f19f940: merely relaxing cwd exposed a placeholder with no process, so closing previously raised before reaching other candidates. The matching session, no-live-agent, non-default-server and normal foreground-shell filters remain.
- Restore regression negative control: executed the new placeholder unit-test function against the base herdr_restore implementation using a stubbed _herdr; it raises CalledProcessError at base and passes at head.
- Printed decisions: the introduced _decided/_exit_for checks cover exit-code, sub-status, finalize-begin, wait-rc and finalize-end process failures. Allowing empty finalize-end output is intentional for OwnerGone, whose CLI handler returns normally without a printed decision. Bash 3.2 dynamic scoping makes printf -v assign the intended caller variables.
- Exit-code negative control: the exact base wrapper placed in a temporary dependency layout with the same failure shim exits 255, while head exits 1. This confirms the added failure test actually detects the original bug.
- N1 accurately documents the package import path to paho; execution code did not change.
- N3 accurately documents PATH python3 and missing-interpreter behavior; executable code did not change.
- Six item-scoped commits, seven changed files, no version bump or VERSIONS entry. No further code defect was identified in the reviewed normal-path handling.
## Verification
- Python 3.9 from the designated issue17-followups/.venv; native /bin/bash 3.2.57.
- With --noconftest, cache disabled, external basetemp and per-test temporary WORKSPACE_ROOT: tests/test_issue17_item4_decisions.py + tests/test_issue45_restore_unit.py -> **281 passed, 4 failed in 292.91s**. All newly added failure-handling and restore unit tests passed. Log: /tmp/70d302c2-selected.log.
- The four failures are existing zombie-owner tests: test_u_an_exited_unreaped_owner_is_not_alive, both test_c_finalize_ends_with_an_unreaped_wrapper parameters, and test_c_an_unreaped_wrapper_leaves_the_real_record_alone. A separate owned-child probe confirmed ps execution raises PermissionError(1, Operation not permitted), causing the existing _alive fallback to return True for an unreaped exited child. The wrapper.py executable AST is identical to base (only its module docstring changed). These sandbox-caused failures are not a new finding against this PR.
- Budget tests with --noconftest and selection "not test_b3 and not test_r3 and not test_b7": **13 passed, 7 deselected in 28.88s**. Log: /tmp/70d302c2-budget.log. This exercises budget propagation, child wait caps, remote deadline labels and closed-unconfirmed classification without needing a reachable broker.
- Explicit loopback bind probe failed with PermissionError(1, Operation not permitted). Consequently no full broker suite result is claimed. Real-herdr restore integration was not run in this sandbox; the orchestrator retains that validation responsibility. Static inspection of its rig confirms disposable HOME/HOME_DIR, mamt45-* session names, scrubbed inherited server selectors and fake agent commands, rather than the user's server/config.
- Both shell files pass /bin/bash -n. All five changed Python files pass AST parsing. git diff --check BASE HEAD passes.
- HEAD and tree match the brief; final git status --porcelain is empty.
- Independent probes reproduced subscriber-up failures returning submit 0 for both module-error rc 1 and interpreter-error rc 127, plus swallowed stall-watch rc 127.
- Negative controls confirmed exit-code failure base 255 -> head 1 and placeholder regression base exception -> head pass.
- The environment restrictions alone do not determine the verdict. R1 is an independently reproduced N2 requirement violation.
## Isolation and lifecycle
All verification ran from the specified review tree. Inherited HERDR_*, MAM_*, MQTT_*, SKS_* and control-plane bindings were cleared; temporary HOME/HOME_DIR/WORKSPACE_ROOT, XDG_CONFIG_HOME and an absent MAM_ENV_FILE were supplied. Tests sourcing lib.sh received a per-test temporary WORKSPACE_ROOT via an environment-only pytest plugin. Direct probes used synthetic jobs and temporary registries. No .mam.env was exported, no live herdr or agent session was used, and no creator/source files, commits or branches were changed. Base comparison artifacts and logs were outside the review tree.
The requested started publication was attempted first; MQTT exhausted three attempts with Operation not permitted and exited 2. The requested completed command was also executed after saving the report; all three MQTT attempts failed with Operation not permitted and it exited 2. The report exists, but broker delivery of started/completed is not confirmed.
[VERDICT: NOT PASS]
@@ -0,0 +1,123 @@
# PR #48 — Codex review, job 714c5189
## 판정: NOT PASS
테스트 이식성 수정은 확인했다. 다만 현재 제품 코드에서 아래 R1–R3을 재현했다. 기존 스위트가 통과해도 설정 검증 실패 시 복원이 다시 켜질 수 있고, 비정상 프로세스 응답에서 pane을 닫으며, 유효한 NaN 설정에서 다른 설정을 유실한다. 국소 수정과 회귀 테스트로 해결 가능한 범위이므로 재계획 escalation은 필요하지 않다.
## 검증 identity 및 범위
- loop: `loop-20261005T081844Z-e92124`
- base: `c5c7eff6f758c76ac37a138dbc3c301f3f7e10cf`
- head: `e8d9151226d8139c7b5688e83d035a3a747aeb2d`
- tree: `8b39188453ff8a7263aa4400792fabc99188dad6`
- 검증 cwd: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261005T081844Z-e92124/1`
- Codex는 PR 전체 `c5c7eff..e8d9151`을 검토했다. 두 후속 커밋은 테스트와 문서만 변경했으며 R1–R3 제품 코드는 이전 head와 동일하다.
- 검증 전후 HEAD/tree 동일, `git status --porcelain` 출력 없음. 제품 코드·테스트·문서는 수정하지 않았다.
## 차단 사항
### R1 — P1: config check 실행 실패를 성공으로 반환해 스키마 오류 파일을 서버에 넘긴다
**근거:** `.agents/skills/lib_py/herdr_config.py:235–237`은 `OSError` 또는 `subprocess.SubprocessError`를 받으면 경고 후 `True`를 반환한다. 호출부 `:265–266`은 해당 파일 경로를 성공 결과로 반환하고, `.agents/skills/lib.sh:255–256`은 그 경로를 서버의 `HERDR_CONFIG_PATH`로 설정한다. `VERSIONS.md:49`의 “config check를 통과해야 한다” 계약과 다르다.
**재현:**
1. Python 3.11에서 임시 원본 파일에 다음을 쓴다.
```toml
[ui]
confirm_close = "invalid-bool"
```
2. `herdr_config.herdr_rejects`가 `subprocess.TimeoutExpired(["herdr", "config", "check"], 30)`를 던지도록 주입하고 `write_server_config({"HERDR_CONFIG_PATH": source}, herdr="/review/fake-herdr")`를 호출한다.
3. 반환된 경로의 내용을 확인한다. 별도로 버리는 HOME/XDG/세션에서 실제 herdr의 `config check`로 그 내용을 검사한다.
**기대:** 검증하지 못한 병합 파일을 “사용 가능한 설정”으로 반환하지 않아야 한다. 최소 설정으로의 안전한 전환을 시도하고, 그것도 검증 불가하면 실패를 명시해야 한다.
**실제:** 유효한 경로와 “the config is unchecked” 경고를 반환하며, 파일에는 `confirm_close = "invalid-bool"`이 남는다. 동일 내용의 실제 herdr 검사는 exit 1, `invalid type ... expected a boolean; using defaults`를 반환했다. 이 파일로 서버가 시작하면 파일 전체가 거부되어 `resume_agents_on_restore=false`도 적용되지 않는다. 검사 실행 timeout을 주입한 재현이며, 실제 운영 서버에서 timeout을 유발하지 않았다.
**수정 방향:** 예외를 성공으로 취급하지 말고 명시적 검사 실패로 처리한다. 최소 설정의 검증에도 실패하면 경로를 반환하지 않도록 하고, 호출부의 기존 실패 경고와 연결한다. timeout/OSError 회귀 검사를 추가한다. `:178`의 알 수 없는 nonzero 종료도 무조건 허용하지 말고, 파일 전체를 버리지 않는 것으로 확인된 경고와 검사 자체 실패를 구별하는 편이 안전하다.
### R2 — P2: 비정상 PID 값의 단순 동등 비교로 pane 닫기를 허용한다
**근거:** `.agents/skills/lib_py/herdr_restore.py:44–47`은 `shell_pid is not None` 및 전경 그룹과의 동등 여부만 검사한다. `:63–65`는 이 결과만으로 `pane close`를 호출한다.
**재현:**
1. `_herdr`를 stub으로 바꾸고 `pane list`가 아래 pane을 반환하게 한다.
```python
{"pane_id": "probe-pane", "agent": None,
"agent_session": {"value": "probe-id"}}
```
2. `pane process-info`가 `{"process_info": {"shell_pid": 0, "foreground_process_group_id": 0}}`을 반환하게 한다.
3. `close_restored_shells("probe-id", session="m45-review-probe")`를 호출하고 stub 호출 이력을 검사한다. 같은 검사를 `False`, 빈 문자열, `-1`로 반복한다.
**기대:** 유효한 셸 PID/전경 프로세스 그룹인지 입증하지 못한 응답은 pane을 그대로 두어야 한다.
**실제:** 네 경우 모두 `("pane", "close", "probe-pane")`를 호출하고 `["probe-pane"]`를 반환했다. 실제 pane은 닫지 않았으며 응답 주입으로 닫기 요청을 확인했다. 현재 실제 herdr가 이 값을 반환한다고 주장하는 것은 아니다. 브리프가 명시한 “응답이 이상할 때 fail-safe” 조건을 위반하는 재현이다.
**수정 방향:** 두 값이 bool이 아닌 양의 정수인지 검사한 뒤 동등 여부를 비교한다. 누락·null·0·음수·문자열·bool 응답에서 닫기 요청이 없는지 회귀 검사한다.
### R3 — P2: 유효한 TOML NaN을 불일치로 판단해 무관한 사용자 설정을 버린다
**근거:** `.agents/skills/lib_py/herdr_config.py:159`의 일반 dict 동등 비교에서 재파싱된 `nan`은 원래 `nan`과 같지 않다. 따라서 `:162–163`이 전체 설정을 MINIMAL로 교체한다. `:112–114`에는 NaN 직렬화 지원까지 있지만 동등 검사가 이를 거부한다.
**재현:**
```python
body, warnings = herdr_config.server_config_text(
"[ui]\nconfirm_close = false\n[custom]\nvalue = nan\n"
)
```
Python 3.11에서 `tomllib.loads(body)`의 `ui.confirm_close`를 확인한다.
**기대:** 원본 설정과 복원 키만 false로 바꾼 결과가 유지되어야 한다. NaN 값의 의미상 동등성도 처리해야 한다.
**실제:** 출력은 `[session]\nresume_agents_on_restore = false\n`뿐이다. `ui.confirm_close=false`가 사라지고 “could not rewrite ... faithfully” 경고가 나온다. 버리는 환경의 실제 herdr 검사에서 원본은 unknown `[custom]` 섹션 경고만 냈으며 `parse error/using defaults`는 없었다. 즉 추가 섹션을 무시하는 것과 정상 UI 설정까지 버리는 것은 다르다. Python 3.11에서도 발생하므로 사용자가 승인한 Python 3.9 최소 설정 폴백과 별개의 문제다.
**수정 방향:** 중첩 dict/list의 구조 및 값 비교 시 두 NaN을 의미상 동등하게 취급한다. 최상위·중첩·배열 NaN을 포함해 무관한 유효 설정이 유지되는 회귀 검사를 추가한다.
## 실행 결과
| 검증 | 결과 |
| --- | --- |
| 지정 Python 3.9, 브리프의 6개 파일 + `tests/test_issue10_13_purge.py` | **175 passed, 30 skipped**, 226.24초 |
| 위 결과 중 purge 파일 | **23 passed**; 실패 없음 |
| Python 3.11, `tests/test_issue45_herdr_config.py` | **51 passed**, 0.42초 |
| 현재 head의 독립 경계 탐침: 검사 timeout 1 + 비정상 PID 4 + NaN 1 | **6 failed**, 0.03초; R1–R3 재현 |
| `git diff --check c5c7eff..HEAD` | 통과 |
| 변경 bash 두 파일 `bash -n`, 신규 Python 두 모듈 AST parse | 통과 |
| 검증 후 작업 트리 / 실제 테스트 서버 정리 | clean / 해당 runner의 서버·세션 잔류 없음 |
30 skips 중 29건은 tomllib 미설치 환경에서 의도된 설정 병합 검사 skip이다. 나머지 1건은 기존 checkout 의존 검사다. 따라서 Python 3.9 결과만으로 3.11 병합 기능이 검증됐다고 간주하지 않았고, 3.11의 51건을 따로 실행했다.
- 선택 테스트 로그: `/tmp/714c5189-selected.log`
- Python 3.11 설정 로그: `/tmp/714c5189-config311.log`
- 독립 탐침: `/tmp/714c5189_extra_probes.py`
- 독립 탐침 로그: `/tmp/714c5189-probes.log`
독립 탐침 재실행(위 검증 cwd에서):
```sh
PYTHONDONTWRITEBYTECODE=1 /tmp/270e8aaa-py311/bin/python -m pytest -p no:cacheprovider -q --basetemp=/tmp/714c5189-probes-rerun /tmp/714c5189_extra_probes.py
```
이 탐침은 실제 herdr를 실행하거나 실제 pane을 닫지 않는다. 임시 파일과 stub만 사용한다.
## 브리프 1–7 확인
1. **병합/충돌:** 기본 TOML 표기, 기존 session 키, 원본 없음·깨짐, 생성 파일의 원본 추적, 원본/외부 파일 충돌 및 폴백 검사는 3.11 스위트에서 통과했다. 원본 파일은 유지되고 생성 파일이 원본 옆에 남는다. 충돌 시 원본별 해시 이름, 쓰기 불가 시 .mam 폴백과 상대 경로 의미 변경 경고가 있다. 단 R1/R3 때문에 전체 무손실·검사 계약은 통과로 볼 수 없다.
2. **기동 경로:** `lib.sh:193–212,241–261`의 named-session headless bootstrap에 적용된다. create/resume도 이 shim을 이용한다. default 서버, MAM_NO_AUTOSTART 조회 경로, 이미 실행 중인 서버, 사용자가 직접 attach로 띄운 서버에는 새 설정을 적용하지 않는다. create SKILL.md:119–120 및 VERSIONS.md:50의 다음 기동/직접 기동 예외 설명과 부합한다. 이미 떠 있는 서버를 재설정하는 기능은 추가하지 않았다.
3. **pane 정리:** 정상 응답의 다른 session id, 사용자 pane, live agent, 셸 이외 전경 프로세스 보호와 create→crash→resume 정리는 실서버 테스트에서 통과했다. 다만 비정상 응답은 R2처럼 닫히므로 fail-safe 항목은 미충족이다.
4. **실서버 테스트/격리:** 후속 수정의 resolve된 임시 HOME 및 macOS ps 환경 조회로 두 A 테스트와 B 테스트가 통과했다. A의 마지막 양성 대조군도 실행돼 일반 서버에서 같은 저장 레이아웃의 agent resume을 확인했다. 실행 wrapper에서 HERDR_*를 모두 제거하고 버리는 HOME/XDG_CONFIG_HOME을 사용했다. 실제 테스트는 uuid4 기반 `mamt45-66197-*` 세션만 사용했으며, 종료 후 해당 서버 프로세스 및 세션을 담은 임시 루트가 없음을 확인했다. 사용자 서버/설정/소켓 및 .mam.env를 사용하거나 변경하지 않았다.
5. **문서/버전/#42:** v4.8.5 bump는 승인 범위이며 일관성 검사를 통과했다. Python 3.9에서 다른 설정을 보존하지 않는 동작은 새 문서에 명시됐고 사용자의 문서화 결정을 차단 사유로 삼지 않았다. 인벤토리는 create 문서의 start/end 줄 번호와 removed 항목의 근거 줄 번호만 변경됐고 class/hash 재분류는 없다. 검사 통과 필수라는 문서와 R1 코드의 불일치는 수정해야 한다.
6. **purge 12건 기준 실패:** 지정 Python 3.9에서는 23건 모두 통과했다. 이전 head의 추가 Python 3.11 실행에서는 같은 호스트에서도 12건이 실패했다. PR에 purge 코드/테스트 변경은 없으며, PR 모듈을 전혀 import하지 않은 SQLite 최소 재현으로 원인을 분리했다: DB 옆에 가짜 WAL/SHM을 만든 뒤 열어 SELECT하면 Python 3.9.6/SQLite 3.51.0은 두 파일을 보존하고, Python 3.11.15/SQLite 3.53.3은 둘 다 제거한다. 기존 `agy.py:74–76`의 DB 검사와 `tests/test_issue10_13_purge.py:83–111` fixture 가정의 환경 차이다. “Linux에서만 발생”으로 단정하면 부정확하지만 이 PR이 도입한 회귀라는 근거는 없다.
7. **Python 경계:** 새 설정 정책은 herdr_config.py, pane 판정은 herdr_restore.py에 있다. resume bash는 호출과 경고만 추가하며, lib.sh는 Python 생성 결과를 서버 환경으로 전달한다. 신규 비즈니스 판정을 bash에 추가한 것으로 보지 않는다.
## 후속 커밋으로 해결된 사항
| 이전 이식성 문제 | 현재 상태 |
| --- | --- |
| Python 3.9에서 무조건 tomllib import로 수집 중단 | 해결: 선택 import와 해당 29건 skip, 3.11에서 별도 51건 통과 |
| macOS에 없는 /proc 환경 조회 | 해결: ps -E 대체 경로로 실제 A 검사 및 양성 대조 통과 |
| /tmp와 /private/tmp 문자열 차이 | 해결: 임시 루트 resolve 후 B 검사 통과 |
| issue9 테스트의 실행 Python과 bootstrap Python 차이 | 해결: PATH python3 기준 검사 및 폴백 경고 검증 통과 |
[VERDICT: NOT 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-codex-01",
"job_id": "75b29d78",
"verdict": "PASS",
"transport": "herdr",
"evidence": "review-tree",
"isolation": "worktree",
"verified_at": "2026-10-20T15:51:16Z"
}
@@ -0,0 +1,33 @@
# Issue #40 교차 코드 리뷰
## 검토 범위와 기준선
리뷰는 브리프가 지정한 immutable 검증 경로 `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261020T133532Z-a1684a/1`에서만 했습니다. HEAD는 `feccfd5e0356c8acf0438bc68bce7ea36ce2588e`, tree는 브리프의 `0f0f33cc22f5e492a0ddfccb6bac5be888c10d1a`와 일치하고, `git status`는 깨끗합니다. diff는 기준선 `ff8b381520ae04e80c73a9e18bfad73d47e853c9` 대비 19개 파일, 1128 insertions / 338 deletions입니다.
검토한 경계는 `autopin.py`와 adapter capture/verify, agy presence-lock 소유 PID 확인, wrapper/create/loop 호출 경로, reconcile drift C 통합, atomic YAML/SQLite 기록, Issue #40/64 회귀 및 기존 tier 테스트입니다. 소스 작업 트리에서는 실행하거나 수정하지 않았습니다. 기준선 checkout으로 테스트를 재실행하지 않았습니다. 리뷰 지침상 지정된 immutable head 검증 경로에서만 실행할 수 있었고 별도 baseline worktree를 만들지 않았기 때문입니다.
## 코드 리뷰 결과
- **agy cross-workspace 및 same-cwd 격리:** cache는 workspace 키에 해당하는 UUID 후보만 제공하며, 후보가 다른 cwd에도 매핑되면 `verify_artifact()`가 거절합니다. sibling UUID 검사는 cache hit와 무관하게 적용됩니다. `capture_active_session_id()`는 pane PID 또는 그 자손이 presence lock/DB 경로를 점유함을 확인하기 전에는 ID를 반환하지 않습니다. lsof/ps, pane PID, 점유자 확인 불능은 fail-closed이고, 다른 행이 이미 UUID를 가졌으면 거절합니다. 이 경로가 `autopin.capture()`와 reconcile drift C에서 공유됩니다.
- **completed 수신과 중복 처리:** wrapper/create 및 loop 복구가 동일 handler를 호출합니다. handler는 terminal status, local transport, session row와 dispatch token을 검사하고 job-scoped flock와 `(job_id, dispatch_token, attempt_id)` marker로 중복 호출을 억제합니다. publisher는 제어 평면에 쓰지 않습니다. pin 성공은 atomic mutation 안에서 ID와 `session_id_source`/`session_id_verified`를 함께 기록합니다.
- **기존 안전장치:** epoch/workspace 검증과 sibling exclusion 이후 단일 후보만 선택하고, viewport mismatch에서는 pin을 막습니다. 모호성은 pin하지 않고 행에 사유를 남깁니다. 실패한 pin은 job의 성공 terminal 상태를 덮어쓰지 않습니다. Claude/Grok 및 이미 ID가 있는 행은 capture 경로에서 보존됩니다.
- **변경 범위:** reconcile의 agent별 후보 검색을 공용 Python capture로 줄이고, create/delegate/loop 호출과 문서·테스트를 더했습니다. stop/purge 구현과 publisher 소유권 경계를 건드리지 않았습니다. 기존 reconcile의 비-0/1 viewport 결과를 degraded pin으로 처리하는 동작은 유지했습니다.
차단성 결함은 발견하지 못했습니다. 비차단 관찰로, reconcile drift C는 `ambiguous`일 때만 구체적 capture 사유를 `last_visible_status`/drifts에 남기고 `unverifiable-owner`, `owner-mismatch`, `rejected`는 조용히 건너뜁니다. completed handler 경로는 이 사유를 기록하므로 안전성 문제는 아니지만, completed 처리 뒤 재시도/운영 진단을 돕기 위해 reconcile에서도 이 세 상태를 warning drift로 노출하면 추적성이 좋아집니다.
## 검증
- `tests/test_issue40_auto_pin.py tests/test_issue64_ro_connect.py`: **70 passed** (42.64s). same-cwd A/B 소유 pane test, presence 소유자 일치/자손/불일치/lsof 불가, 다른 workspace 거절, completed dedupe, ambiguous 및 publisher 경계 포함.
- `tests/test_issue60_linux_fixes.py tests/test_issue60_opencode_v2.py tests/test_tier1_unit.py tests/test_tier2_component.py`: **176 passed** (390.99s).
- 변경 shell 파일(`lib.sh`, create script, delegate wrapper, reconcile): `bash -n` 통과.
- `git diff --check ff8b381..HEAD`: 통과.
기준선에서 새 테스트가 실패하고 HEAD에서 통과한다는 A/B 재실행은 이 리뷰의 격리 경로 제한상 수행하지 않았습니다. HEAD 테스트와 코드 경로 검토만으로 verdict를 냈습니다. 전체 테스트 스위트는 브리프대로 상위 오케스트레이터 검증에 남깁니다.
## 남은 위험
agy 자동 pin은 `lsof`와 `ps`의 소유자 계보 확인에 의존하므로 제한된 권한 환경에서는 정상 대화도 자동 등록되지 않을 수 있습니다. 현재 동작은 오등록보다 false negative를 택해 안전하게 실패합니다. process snapshot과 completed event 처리 사이의 race는 이미 종료된 후보를 보수적으로 거절할 수 있지만, 다른 pane의 ID를 승인하게 하지는 않습니다. 위 reconcile 진단 개선 외에 설계 재계획이 필요한 문제는 발견되지 않았습니다.
리뷰 가능한 변경분에서 자동 pin의 핵심 안전 경로를 확인했고, 관련 회귀 묶음이 모두 통과했습니다. 다른 workspace 또는 동일 cwd sibling의 agy 대화를 잘못 pin할 차단성 우회는 발견하지 못했습니다.
[VERDICT: PASS]
@@ -0,0 +1,14 @@
{
"loop_id": "loop-20261012T123226Z-e64a91",
"base_sha": "efd27def47a4d67684694ccf43a6ebeede01620f",
"head_sha": "21f0c12b9e2195cba8e196d6ccc2469173302e8a",
"tree_sha": "c2357ac01824d099fcbb7e9f7433b6b05f5e86a6",
"review_commit": "21f0c12b9e2195cba8e196d6ccc2469173302e8a",
"reviewer": "planner-reviewer-codex-01",
"job_id": "7ac94ed6",
"verdict": "PASS",
"transport": "herdr",
"evidence": "review-tree",
"isolation": "worktree",
"verified_at": "2026-10-12T12:38:13Z"
}
@@ -0,0 +1,59 @@
# Job 7ac94ed6 review
판정: PASS. 지정된 변경 2개 파일에서 차단할 결함을 발견하지 않았다. 이전 잡 4d9c73e5의 임시 산출물을 사용하지 않고 코드 검토, 테스트 실행, 실서버 탐침을 새로 수행했다. 소스 수정·커밋·push는 하지 않았다.
## 검증 identity
- loop: `loop-20261012T123226Z-e64a91`
- base: `efd27def47a4d67684694ccf43a6ebeede01620f`
- head: `21f0c12b9e2195cba8e196d6ccc2469173302e8a`
- tree: `c2357ac01824d099fcbb7e9f7433b6b05f5e86a6`
- 검증 cwd: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261012T123226Z-e64a91/1`
- 실행 Python: `/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux-wt/pr-56-fix/.venv/bin/python` (3.9.6); macOS, bash 3.2, herdr 0.9.3.
검증 시작과 종료 시 HEAD/tree가 지정 값과 일치했고 `git status --short`는 비어 있었다. `git diff --check efd27def47a4d67684694ccf43a6ebeede01620f` 및 `/bin/bash -n .agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh`가 통과했다.
## 코드 검토 결과
1. **응답한 서버로 라벨을 기록하는 전제가 맞다.** `.agents/skills/lib.sh:185–189`의 `-L` 처리는 `HERDR_SESSION_NAME`을 설정하고, `:270–283`의 `_real_herdr`는 그 이름을 실제 herdr의 `--session`으로 전달한다. `:1256–1274`의 `ls`도 이 경로를 사용한다. `reconcile.sh:505–530`에서 default 스캔은 `-L`을 생략하므로 상속된 이름의 서버가 응답한다. 새 `reconcile.sh:664–667`은 비어 있지 않은 비-default 상속 이름에 한정해 라벨을 보정하고, `:681–687`의 두 서버 필드와 start/attach/kill 명령에 일관되게 반영한다. 실제 일회용 herdr로도 전제를 확인했다.
2. **중복 등록 및 이후 drift A 오판이 관측되지 않았다.** `reconcile.sh:494–502`는 환경과 기존 YAML의 서버를 수집한다. 저장된 명시적 서버는 다음 실행의 환경이 제거되거나 바뀌어도 스캔 대상이다. `:607–611`의 이름 검사와 `:714–715`의 등록 직후 이름 추가가 default/명시적 서버 스캔에서 같은 pane이 두 번 발견될 때 중복을 막는다. 실서버 탐침에서 양쪽 alias가 alive에 존재해도 등록 행과 최초 drift B는 각각 하나였다. 후속 환경 변경 실행에서도 행은 running이고 drift A/B가 없었다.
3. **기존 default 행의 처리 범위는 유지된다.** 수정은 새 drift B 행 생성에 국한된다. pane_meta와 drift A, 기존 행의 처리는 변경하지 않는다. 실서버 탐침에서 기존 default 행은 재라벨링 없이 running으로 남았다. 상속 서버가 없는 일반 default 경로는 기존 scenario3 회귀 테스트로도 확인했다. 이 수정은 기존 default 행의 마이그레이션을 제공하지 않는다.
4. **create의 F2 서버 일치 조건과 맞는다.** `create_session.sh:540–543`은 monitor 행의 서버가 현재 create 서버와 같아야 인수한다. 새 라벨은 이 조건을 만족시키며 다른 서버 행을 인수하도록 조건을 완화하지 않는다. 같은 폴더 6개 병렬 create 사례 및 pane-lock 회귀 테스트가 통과했다.
5. **코드 블록 메타데이터 갱신이 정확하다.** `tests/data/skill_code_blocks.json:620`의 참조는 실제 `create_session.sh:550`의 `raise SystemExit(4)`를 가리키고 관련 검증이 통과했다.
## 새로 수행한 검증
총 **51 passed, 1 skipped**. 모든 실행의 exit code는 0이다.
| 검증 | 결과 | 근거 |
|---|---|---|
| `test_tier4_e2e.py::test_e2e_scenario4_parallel_flock_locking`, `::test_e2e_scenario3_drift_auto_reconciliation`, `test_issue50_pane_lock.py`, `test_issue42_skill_blocks.py` | 50 passed, 1 skipped, 97.61초 | `/tmp/7ac94ed6-review/tests.log` |
| 새 실제 herdr 서버/환경 변경 탐침 | 1 passed, 10.89초 | `/tmp/7ac94ed6-review/probe.log`, `/tmp/7ac94ed6-review/test_probe.py` |
건너뛴 1건은 `tests/test_issue42_skill_blocks.py:266`: 추적되지 않는 `.claude/skills`가 리뷰 worktree에 없어서 발생한 기존 조건부 skip이다. scenario4는 mock herdr를 사용하는 회귀 테스트이며, 6개 병렬 create의 성공과 고유 행을 검증한다. 이를 실제 herdr 병렬 create 검증으로 간주하지 않았다.
재현 launcher: `/tmp/7ac94ed6-review/run.py`. 지정 Python으로 다음 인자를 전달한다.
```text
/tmp/7ac94ed6-review/run.py tests/test_tier4_e2e.py::test_e2e_scenario4_parallel_flock_locking tests/test_tier4_e2e.py::test_e2e_scenario3_drift_auto_reconciliation tests/test_issue50_pane_lock.py tests/test_issue42_skill_blocks.py
/tmp/7ac94ed6-review/run.py probe
```
launcher는 검증 cwd를 고정하고, 상속 HERDR_*/MAM_*/MQTT_*/SKS_* 및 지정 경로 환경변수를 제거한다. 일회용 HOME, 검증 트리 밖 basetemp, `PYTHONDONTWRITEBYTECODE=1`, `-p no:cacheprovider`, 지정 `AGENT_PYTHON_BIN`을 사용한다. 일반 테스트 root는 `/tmp/7ac94ed6-u75tye4e`, 탐침 root는 `/tmp/7ac94ed6-2ppzeojn`이었다.
### 실제 herdr 탐침 절차와 기대/실제 결과
일회용 HOME과 서버 `review7-a88dd0c7`에서 fake Claude pane `t50-creator-claude`를 실제 herdr로 시작하고, 빈 임시 YAML을 준비했다. 실제 운영 서버나 사용자 에이전트 홈, `.mam.env`, 운영 모니터 데몬은 사용하지 않았다.
1. 상속 `HERDR_SESSION_NAME`으로 실행한 무옵션 `herdr ls`와 명시적 `-L review7-a88dd0c7` 조회가 같은 pane을 반환해야 한다. **실제: 두 출력 일치.**
2. 실제 `reconcile.sh --once --emit-diff` 실행 시 default와 명시적 서버 스캔이 같은 pane을 보더라도 하나의 행만 등록하고 서버 필드·제어 명령은 실제 이름을 사용해야 한다. **실제: alive에 두 alias, 행 1개, 최초 B 1개, A 없음, 두 필드와 명령 모두 실제 이름.**
3. 같은 환경에서 다시 실행해도 중복이나 terminated 판정이 없어야 한다. **실제: running 1개, 추가 A/B 없음.**
4. `HERDR_SESSION_NAME`을 제거한 다음 실행하고, 이어서 존재하지 않는 다른 일회용 이름으로 바꿔 실행해도 YAML의 명시적 서버를 조회해야 한다. **실제: 두 실행 모두 기존 서버 pane을 발견, running 유지, A/B 없음.** 다른 이름의 서버는 자동 시작하지 않도록 설정했다.
5. 임시 기존 행의 두 서버 필드를 default로 설정한 뒤 원래 상속 환경에서 실행하면 기존 행의 처리 방식이 유지되어야 한다. **실제: default 라벨 유지, running, A 없음.**
6. finally에서 탐침 전용 서버와 HOME을 정리하고 잔존 프로세스가 없는지 검사했다. **실제: 정리 및 잔존 검사 통과.**
## 검증 범위의 한계
전체 프로젝트 스위트는 이 잡에서 실행하지 않았다. 장기 실행 데몬을 시작하지 않았으며 환경 변경은 실제 `--once` 연속 실행과 코드 검토로 검증했다. 실제 herdr 탐침의 에이전트 명령은 fake Claude이므로 LLM 업무 수행 자체를 검증하는 시험은 아니다. 이 한계 내에서 요청된 서버 라벨·중복·후속 스캔·create 인수 회귀에 대한 차단 사유는 없다.
[VERDICT: PASS]
@@ -0,0 +1,93 @@
# PR #58 Mac review — NOT PASS
Job: `863e2edf`. Role: Reviewer. 소스 수정 없이 리뷰 및 임시 디렉터리 안의 검증만 수행했다.
- loop: `loop-20261014T152849Z-f1a415`
- base: `e32c9574644a9185813a4dc881812694d62b6204`
- head: `63d816468e6d48d01bd96f31d8a25eeadfbaab60`
- tree: `e8f162f74b7ce8ee8cc6ba7954b7d28dfa3f89f6`
- 검증 트리: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261014T152849Z-f1a415/1`
- 범위: base..head 11 commits / 21 files. 아래 파일:라인은 이 head 기준이다.
관련 테스트 **77 passed in 177.28s**, exit 0. 그러나 테스트가 다루지 않는 두 경우를 추가 탐침으로 재현했다. 병합 전에 아래 두 항목을 수정하고 회귀 테스트를 추가해야 한다. 국소 수정으로 해결 가능하므로 설계 재계획 에스컬레이션은 필요하지 않다.
## R1 — [P1] 디스크 폴백이 다른 dispatch의 완료를 이전 dispatch의 성공으로 오인한다
근거:
- `.agents/skills/lib_py/delegation/job_subscriber.py:89`: 레지스트리의 status/token/attempt/transition_seq를 먼저 읽는다.
- 같은 파일 `:100`, `:114`, `:121`: 나중에 읽은 감사 상태의 seq가 그 스냅샷 이상이면 상태를 채택한다.
- 같은 파일 `:126`, `:132`: 토큰/attempt 검증에는 여전히 처음 읽은 레코드만 사용한다.
- `.agents/skills/lib_py/delegation/engine.py:322`: redispatch는 새 토큰을 발급한다. 감사 상태에는 토큰/attempt가 기록되지 않는다(`:383`).
재현 절차:
1. 임시 cwd/WORKSPACE_ROOT 및 별도 registry에서 토큰 A로 잡을 등록하고 `started`로 전이시켜 running 상태로 만든다.
2. `_check_disk_fallback`을 expected token A로 실행한다.
3. `read_logged_status`를 감싸서, 레지스트리 read가 끝나고 감사 read가 시작되는 지점에 다음 실제 engine 전이를 실행한다: A의 `error` → `redispatch(attempt_id=2)` → 새 토큰 B로 `completed`.
4. 실제 `read_logged_status` 결과를 반환한다. 즉 감사 파일을 조작한 것이 아니라 정상 engine 전이 사이에 subscriber가 일시 중단된 순서를 결정적으로 재현했다.
기대: 토큰 B의 성공으로 A의 대기를 완료하면 안 된다. 최신 레코드와 비교해 토큰 변경을 거부하거나 A의 실제 실패를 구분해야 한다.
실제: 초기 레코드는 A/running이고 최종 레코드는 B/completed인데, `pending=[]`, `terminal={job_id: "completed"}`가 된다. 출력도 `completed [disk-fallback]`이다. 이전 시도가 실패했는데도 호출자가 성공으로 종료할 수 있다.
탐침 출력 발췌:
```text
RACE {"initial_status": "running", "initial_token": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "latest_token": "7b761cb2ea2d4514bbd631467997ad9d", "pending": [], "terminal": {"5b5cd6e8": "completed"}}
```
위 토큰은 임시 테스트 잡의 식별자이다. 이전 감사 상태를 거부하는 현재 회귀 테스트는 통과하지만, **레코드를 읽은 이후 다음 attempt의 더 새로운 감사 상태가 도착하는 경우**는 검사하지 않는다. seq는 최신성만 나타내며 dispatch identity를 보장하지 않는다.
수정 방향: 감사 상태를 채택하기 직전에 레지스트리를 다시 읽어 token/attempt/seq가 같은 관찰에 속하는지 확인하고 변경 시 재시도/거부한다. 또는 레지스트리 lock 아래에서 일관된 관찰을 보장한다. 감사 상태에 dispatch identity를 기록·검증하는 방식도 가능하다. 단순히 `aud_seq >= rec_seq`만으로 다른 시도의 상태를 채택하지 않도록 위 interleaving을 회귀 테스트로 추가한다.
## R2 — [P2] 레지스트리 자체가 심볼릭 링크이면 submit과 logs의 로그 경로가 갈라진다
근거:
- `.agents/skills/lib_py/delegation/mqtt_common.py:189`: sibling 계산은 `abspath`만 사용한다.
- `.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job:84`: submit의 `_abs_registry_dir`는 `cd -P`/`pwd -P`로 링크를 해석한다.
- 같은 파일 `:280`: 물리 경로로 바꾼 다음 로그 루트를 계산한다.
- 같은 파일 `:1108`: logs는 전달받은 링크 경로 그대로 registry CLI에 넘긴다.
- `.agents/skills/lib_py/control_plane.py:34`, `:52`: control-plane binding도 realpath로 정규화하므로 직접 Python resolver와 다를 수 있다.
재현 절차(모두 임시 디렉터리 안에서, DELEGATE_JOB_LOGS_DIR 없이):
1. `<tmp>/actual/jobs`와 `<tmp>/alias`를 만들고 `<tmp>/alias/jobs -> <tmp>/actual/jobs` 심볼릭 링크를 만든다.
2. cwd와 WORKSPACE_ROOT를 `<tmp>`로 둔다.
3. `/bin/bash <review>/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job submit --dry-run --agent human --registry-dir <tmp>/alias/jobs --workdir <tmp> --prompt symlink-wrapper` 실행.
4. 생성된 job ID로 같은 wrapper의 `logs <id> --registry-dir <tmp>/alias/jobs` 실행.
기대: 동일한 registry 인수로 만든 잡의 감사 로그를 조회할 수 있어야 한다.
실제: submit은 rc 0이며 `<tmp>/actual/delegate_job_logs/<id>`에 감사 로그를 생성하지만, logs는 `<tmp>/alias/delegate_job_logs`에서 찾아 rc 1로 실패한다.
```text
job_id: a6cdb85e
submit_log_root: /private/tmp/review-863e2edf-m7fmcbsw/probes-igd0iy09/actual/delegate_job_logs/a6cdb85e
actual_audit_exists: true
logs_rc: 1
no audit log for job a6cdb85e under /tmp/review-863e2edf-m7fmcbsw/probes-igd0iy09/alias/delegate_job_logs
```
별도 직접 registry 호출도 같은 레코드를 가리키면서 alias 옆에 로그를 쓰고, control_plane.from_env는 actual 옆을 기록함을 확인했다. `/tmp -> /private/tmp`처럼 **부모 전체가 같은 위치를 가리키는 별칭**은 통과하며, 이 문제는 **jobs 디렉터리 자체가 다른 부모 아래로 연결되는 링크**에서 발생한다.
수정 방향: 기본 감사 경로를 산출하기 전에 registry 경로를 정규화하는 정책을 중앙 resolver에 통일한다. 예를 들어 registry의 realpath에서 sibling을 구하면 submit/control_plane과 일치한다. 명시적 DELEGATE_JOB_LOGS_DIR 우선권은 유지한다. `submit --dry-run` 후 동일한 symlink 인수로 `logs`를 조회하는 회귀 테스트를 추가한다.
## 검증 결과 및 범위
- 실제 Mac Python 3.9.6에서 변경된 Python 13개 파일 AST 파싱 통과. 변경된 shell/wrapper 5개를 각각 `/bin/bash -n`으로 검사하여 통과. Bash는 `3.2.57(1)-release (arm64-apple-darwin25)`이다.
- 관련 pytest 77건 통과: `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` 전체; `test_tier1_unit.py::test_b9_logs_dir_follows_cwd_changes`; issue18의 subscriber state/seq/liveness 및 remote redispatch 관련 4건.
- 실행은 지정 venv Python, `PYTHONDONTWRITEBYTECODE=1`, `-p no:cacheprovider`, 검증 트리 밖 `/tmp/review-863e2edf-m7fmcbsw/pytest` basetemp를 사용했다. 지정된 HERDR/WORKSPACE_ROOT/MAM/DELEGATE 환경변수를 `env -u`로 제거했고, HOME도 임시 위치로 격리했다. create/resume은 mock fixture를 사용했다. 실제 에이전트/monitor/herdr 서버를 시작하지 않았다.
- 일반 기본 경로, 명시적 로그 override, 상대 registry, 이동 registry, control-plane row 및 prefix, worktree create/resume, audit-only status, 오래된 감사 seq 거부는 관련 테스트로 검증했다.
- 추가 Mac 부모 경로 별칭 탐침(`/tmp`와 `/var`): 정규화된 binding에서 sibling 로그 키를 전달하지 않는 동작 통과.
- `git diff --check base HEAD` 통과. 검증 후 HEAD/tree 일치 및 clean git status 확인. 소스/문서 변경 없음.
- 전체 스위트는 이 리뷰에서 실행하지 않았다(브리프에 따라 오케스트레이터가 별도 실행). 알려진 purge 모듈도 이번 선택에 포함되지 않았다. 선택 테스트에서는 #55 가드 오탐이나 teardown 오류가 없었다.
- 테스트의 Linux 결과를 Mac 결과로 대신 인용하지 않았다. 검증 트리 바깥 creator 소스에서 테스트를 실행하지 않았다.
재현 스크립트: `/tmp/review-863e2edf-m7fmcbsw/probes.py` (실제 대상 모듈 import, 임시 디렉터리 안에서 실행).
탐침 로그: `/tmp/review-863e2edf-m7fmcbsw/probes.log`.
선택 테스트 로그: `/tmp/review-863e2edf-m7fmcbsw/pytest.log`.
재실행: 검증 트리 cwd에서 `PYTHONDONTWRITEBYTECODE=1 /Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.venv/bin/python /tmp/review-863e2edf-m7fmcbsw/probes.py`. 스크립트가 제어 평면 관련 환경을 제거하고 cwd/WORKSPACE_ROOT/HOME을 새 임시 디렉터리로 설정한다.
[VERDICT: NOT 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-codex-01",
"job_id": "8db89564",
"verdict": "PASS",
"transport": "herdr",
"evidence": "review-tree",
"isolation": "worktree",
"verified_at": "2026-10-13T03:32:19Z"
}
@@ -0,0 +1,54 @@
# 테스트 격리 수정 리뷰 — 8db89564
판정: PASS. u1e의 체크아웃 쓰기 원인을 정확히 수정했고, 추가된 W1 노드가 그 수정의 제거를 실제로 검출합니다. 차단할 결함은 발견하지 못했습니다.
## Identity 및 변경 범위
- loop: `loop-20261013T025803Z-c69408`
- base: `42e2a7ea9bd3e2770ed8626d789193428ca3aa8b`
- head: `3a30d7da894cdac26b7ec054b4e66fe9fa3dc8db`
- tree: `bd43e4642f0109091f5f497b2703dcbaf8e2616b`
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261013T025803Z-c69408/1`
- 변경은 테스트 두 파일뿐입니다. 시작과 종료 시 HEAD/tree 일치 및 clean 상태를 확인했고, 종료 시 리뷰 체크아웃에 `.mam`이 없음을 확인했습니다. 리뷰 트리나 creator 작업 트리를 수정하지 않았습니다.
## 원인과 수정 검토
`tests/test_issue50_dispatch.py:169–170`은 래퍼 subprocess에 `WORKSPACE_ROOT=str(tmp_path)`를 명시하여 상속된 루트와 체크아웃 기본값을 모두 덮어씁니다.
원인은 `.agents/skills/lib.sh:26`이 변수 미설정 시 라이브러리 위치에서 체크아웃 루트를 계산하고, 3168행에서 `_init_herdr_isolation`을 무조건 호출하기 때문입니다. 이 함수의 150–157행은 `$WORKSPACE_ROOT/.mam/shim`을 만들고 shim 파일을 씁니다. 래퍼 `.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job:53`은 dry-run 처리를 하기 전에 이 라이브러리를 source합니다. 따라서 기존의 임시 HOME/cwd 설정만으로는 격리가 되지 않으며, 이번 명시적 루트 설정이 필요한 최소 수정입니다.
u1e의 원래 검증(미전달 dry-run 레코드는 admission을 막지 않고, running 또는 일반 pending 레코드는 막음)은 175–186행에 그대로 남아 있습니다. 새 환경 설정이 테스트를 우회하거나 assertion을 약화하지 않습니다.
`tests/test_issue17_workspace_root_isolation.py:43`의 노드 추가는 적절합니다. W1은 복사본의 루트를 의도적으로 상속시키고(62행), 내부 테스트가 모두 실행·통과했는지 검사한 뒤(67–69행), 복사본 `.mam`의 생성 자체를 확인합니다(70–72행). 이 때문에 u1e가 환경을 다시 덮어쓰지 않으면 회귀를 잡습니다.
## 실행 검증
아티팩트 루트: `/tmp/review-8db89564-gw9g__7l`
1. **수정된 리뷰 트리: 51 passed, 68.05초, 종료 코드 0.**
- 대상: `tests/test_issue50_dispatch.py` 전체와 `tests/test_issue17_workspace_root_isolation.py` 전체(W1/W2 포함).
- 로그: `/tmp/review-8db89564-gw9g__7l/positive.log`.
- W1 내부 노드 실행의 통과·누수 부재 검사도 통과했습니다.
2. **수정 제거 대조군: W1이 기대한 위치에서 실패, 30.06초, pytest 종료 코드 1.**
- 현재 HEAD의 추적 파일을 임시 체크아웃으로 복사했습니다. 새 W1 NODES는 유지하고, `tests/test_issue50_dispatch.py`만 base 버전으로 교체했습니다.
- W1의 내부 테스트 실행 및 통과 수 검사를 지난 뒤, 71행에서 `tests wrote into the checkout they ran in: ['.mam/shim', '.mam/shim/herdr']`로 실패했습니다.
- 이는 단순 실행 오류가 아니라 원래 누수에 대한 회귀 검출입니다.
- 로그: `/tmp/review-8db89564-gw9g__7l/negative.log`.
- 수정 전 코드와 누수가 생긴 중첩 복사본을 포함한 negative 임시 트리는 검증 뒤 삭제했습니다.
3. **`git diff --check 42e2a7e..HEAD`: 성공.**
두 실행은 요구된 `env -u HERDR_SOCKET_PATH -u HERDR_PANE_ID -u HERDR_TAB_ID -u HERDR_WORKSPACE_ID -u HERDR_SESSION_NAME -u HERDR_SESSION -u WORKSPACE_ROOT -u MAM_ENV_FILE`을 적용했습니다. 외부 가상환경 Python, `PYTHONDONTWRITEBYTECODE=1`, `-p no:cacheprovider` 및 리뷰 트리 밖 basetemp를 사용했습니다. 시작 환경의 HOME/XDG_CONFIG_HOME도 임시 경로로 설정하고 에이전트·제어 평면 관련 상속 변수를 제거했습니다. 실제 서버나 에이전트를 실행하지 않았으며, #55의 다른 에이전트 대화 파일 관련 오탐도 발생하지 않았습니다.
## 유사 경로 점검 및 한계
래퍼 실행 및 `source lib.sh` 검색 결과의 주요 테스트 헬퍼를 추가로 검토했습니다. 살펴본 경로에서는 동일한 누락을 발견하지 못했습니다.
- `test_issue18_liveness_contract.py:54–60`, `test_issue14_delivery_ack.py:32–36`, `test_issue33_budget.py:42–47`, `test_issue34_job_tree.py:50–64`, `test_issue36_prompt_transport.py:47–61`, `test_issue26_envfile.py:73–76`, `test_issue26_ints.py:127–129`: 명시적으로 임시 WORKSPACE_ROOT 설정.
- `test_issue17_item4_decisions.py:225–233`, 689–699: 래퍼 source/submit 환경에서 임시 루트를 고정.
- `test_issue6_fail_closed.py:21–23`, `test_version_consistency.py:11–13`, `test_b19_headless_reconcile_fixes.py:11–13`, `test_b8_send_keys_verification.py:5–7`: 모듈의 `own_workspace_root` 픽스처 사용.
- `test_issue21_worktree_prereqs.py`의 `_dry_submit` 호출부 92–149행은 루트를 전달하며, legacy loop 테스트 398–408행은 샌드박스 루트를 명시합니다.
- `test_issue20_state_isolation.py:192–202`, `test_workspace_scope.py:30–32`, `test_layout.py:318`: 호출 환경에서 루트 지정. `test_a4_adapter_contract.py`의 `test_wait_for_tui_ready_missing_tokens_diagnostic` 및 `test_issue5_phase1_delegation.py`의 `_run_helper`는 샌드박스 픽스처·복사본을 사용합니다.
전체 테스트 스위트를 실행하거나 모든 동적 호출 경로의 무누수를 증명한 것은 아닙니다. 이번 수정에 직접 관련된 51건 및 W1의 수정 제거 검증으로 동작과 회귀 방지 효과를 확인했습니다.
[VERDICT: PASS]
@@ -0,0 +1,102 @@
# Issue #17 WORKSPACE_ROOT test isolation — Codex review
Job: 9bc1f200
Loop: loop-20261008T133858Z-9e7f5b
Base: 6b00b6e06f55299cb60f156ccef09ede4bae5bdd
Head: d198dd50aaf6119dc21f36d88704e64850e67413
Tree: 114ec21d3fc7de8932f5e1d34f895edd24ad7c01
Validation path: /var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261008T133858Z-9e7f5b/1
## Conclusion
NOT PASS. The local _env fix and its new regression are correct, but task item 2 remains incomplete: equivalent unsafe test helpers are still present, including paths not protected by mam_sandbox and a cached environment that bypasses that fixture. These are independently reproduced isolation defects, not a verdict based on sandbox restrictions. Product-code changes are not required.
## R1 — other helpers still write into an inherited workspace (P2)
Evidence:
- tests/test_b19_headless_reconcile_fixes.py:124-134 copies os.environ and sources the checkout's lib.sh before replacing _init_herdr_isolation with a no-op. That replacement is too late: source-time bootstrap has already written the shim. Several callers, e.g. test_fullscreen_tip_is_not_a_blocking_dialog, do not request mam_sandbox.
- tests/test_loop_cli.py:13-23 copies os.environ and executes the checkout's run_loop.sh. Its test_help_lists_creator_not_target_agent (:30) has no mam_sandbox. run_loop.sh:17 sources lib.sh even for --help.
- lib.sh:26 defaults an absent WORKSPACE_ROOT to the checkout, and :144-152 creates .mam/shim and writes a shim temporary file. Thus merely deleting the inherited variable is insufficient.
Reproduction, performed only with temporary directories:
1. Scrub inherited HERDR_*, MAM_*, MQTT_*, SKS_* and control-plane bindings, set HOME to a temporary directory, and set WORKSPACE_ROOT to a new empty temporary sentinel.
2. Import test_b19_headless_reconcile_fixes and call _run_lib_helpers('echo probe-ok').
3. In a fresh sentinel, import test_loop_cli and call _run_loop(['--help']).
4. Expected: sentinel remains empty. Actual: both create sentinel/.mam/shim/herdr; the helper exits 0 and the help invocation exits its normal 1.
A normal pytest session does not solve the unset case: tests/conftest.py:138-146 drops an external root at configure time, but _isolate_mam_state_dirs (:161-178) sets only registry/log/env-file paths. Only mam_sandbox (:215) pins WORKSPACE_ROOT. These callers do not use that fixture, so the checkout fallback remains. The state guard snapshots jobs and audit logs, not shim files (tests/_mam_state_guard.py:79-86).
Fix direction: establish a temporary per-test WORKSPACE_ROOT before every source/bootstrap call, preferably via the common function-scoped isolation fixture for ordinary runtime environment copies; preserve later explicit test overrides. Helpers intended for direct/--noconftest use should also build their environment from their own temporary path, as the revised Issue #17 helper does. Cover the candidates below, add sentinel checks, and audit intentional unset-workspace tests separately instead of changing their behavior blindly.
## R2 — onboard's import-time environment bypasses later fixture isolation (P2)
Evidence: tests/test_orc_onboard.py:15 captures SCRUBBED_ENV at import; :22, :34, :47 and :65 reuse it. The helpers select temporary YAML/HOME_DIR paths but never set WORKSPACE_ROOT before sourcing the original LIB_SH (:41, :55, :64).
Reproduction:
1. Set WORKSPACE_ROOT to an empty temporary sentinel before importing test_orc_onboard.
2. After import, set os.environ['WORKSPACE_ROOT'] to a different temporary fixture workspace, as mam_sandbox would.
3. Call run_find_uuid(str(fixture_workspace), 'claude') with no real user data or agent session.
4. Expected: the sentinel stays empty and any bootstrap shim belongs to the fixture workspace. Actual: the cached sentinel receives .mam/shim/herdr, and the fixture workspace receives no shim.
With normal pytest's configure-time scrub, the cached dictionary instead lacks WORKSPACE_ROOT; later mam_sandbox updates still cannot change that dictionary, so lib.sh falls back to the checkout. This needs explicit treatment even if R1 is solved with a function-scoped autouse fixture.
Fix direction: preserve the intended removal of agent identity variables, but set WORKSPACE_ROOT explicitly from ws/cwd/mam_sandbox in every sourced-shell environment, including dump_state and the inline shell call sites. Alternatively build the scrubbed environment at invocation time after fixtures run, still pinning the temporary root. Add a regression where import-time and invocation-time roots differ.
Probe source: /tmp/9bc1f200-probes.py.
All three reproduced leaked shims are under /private/tmp/9bc1f200-probe-z4ewh5mh; no real workspace was used as a sentinel.
## Candidate audit
I searched tests for os.environ copies/comprehensions, WORKSPACE_ROOT, source/lib.sh, wrapper and run_loop.sh calls, then checked the caller fixtures and environment overrides.
| Candidate / file:line | Disposition and reason |
| --- | --- |
| test_issue17_item4_decisions.py:224 | Fixed correctly: pins tmp_path; extra explicit overrides still apply last. |
| test_b19_headless_reconcile_fixes.py:124, :329, :369, :430 | Unfixed; R1 helper and direct shell calls source original lib.sh without a temporary root. |
| test_loop_cli.py:13 | Unfixed; R1. Later _loop_env at :91 pins a sandbox, but early CLI tests do not use it. |
| test_issue5_phase3_loop.py:145, :154, :172 | Same unfixed bootstrap class: checkout run_loop.sh with ambient environment and no mam_sandbox. |
| test_issue5_phase4_runtime.py:126, :152 | Same unfixed class in run_loop.sh help/freeze checks. tmp_path is available but not bound to WORKSPACE_ROOT. |
| test_o3_scoped_guard.py:376, :412, :458 | Same unfixed class in checkout run_loop.sh invocations; --help also sources lib.sh. loop_lock-only snippets are a separate, non-shim path. |
| test_version_consistency.py:28, :131 | Same unfixed class: version checks source original LIB_SH with implicit/copied environment, without mam_sandbox. |
| test_workspace_scope.py:22-30 | Same unfixed class: unittest creates a temporary project but does not bind WORKSPACE_ROOT before two sources of original lib.sh. |
| test_b8_send_keys_verification.py:5-34 | Same unfixed class: tmp_path argument is unused for workspace binding before source. |
| test_issue6_fail_closed.py:1220-1244 | Same unfixed class: the direct source in the pinned-token test binds MAM_JOBS_DIR but not WORKSPACE_ROOT. Other _env-based cases in this file bind cwd explicitly. |
| test_orc_onboard.py:15-65 | Unfixed cached environment; R2. A later mam_sandbox fixture alone does not protect it. |
| test_issue33_budget.py:43-47; test_issue14_delivery_ack.py:33-36; test_issue18_liveness_contract.py:55-58 | No equivalent defect in these helpers: explicitly pin home/cwd as WORKSPACE_ROOT. |
| test_issue34_job_tree.py:51-64; test_issue36_prompt_transport.py:48-61; test_issue26_envfile.py:74-76; test_issue26_ints.py:123-124; test_issue30_stall_watch_stop.py:30-32 | Explicitly pin temporary home/workspace. Later Issue #34 _run_lib users request mam_sandbox. |
| test_issue21_phase2_loop_a.py:49, :59 | Explicit temporary root override. |
| test_issue21_worktree_prereqs.py:41-55 | Removal is intentional for publisher-command tests that must operate without workspace bindings; actual _dry_submit callers at :92, :114, :143 supply temporary WORKSPACE_ROOT. Not a blanket reason to change this helper. |
| test_issue5_phase1_delegation.py:49; test_tier1_unit.py:14; tier2/3/4 helpers; test_issue19_subscriber_daemon_lifecycle.py:52 | Ordinary helpers consume mam_sandbox's runtime environment; fixture :215 pins the workspace. Unlike onboard, these copies are made at invocation time. |
| test_issue9_server_env.py:84, :108; test_issue23_24_startup_path.py:36; test_a4_adapter_contract.py:558 | Source/bootstrap callers use mam_sandbox; intentional server-marker tests retain that temporary workspace binding. |
| test_issue45_restore.py:55, :179 | rig depends on mam_sandbox, then creates disposable HOME/server state. |
| test_issue45_herdr_config.py:302; test_issue36_prompt_transport.py:427 | Python config CLI or diff_collect/lib_int paths; do not bootstrap lib.sh's shim. |
| test_deploy_layout.py:16 and deploy tests | Installer uses explicit temporary installation targets; it does not source lib.sh for bootstrap. |
| test_issue5_phase2a_loop.py:750-757 | The source invocation is a string for a static scanner test, not executed. |
| test_issue20_state_isolation.py:199-201 | Explicit temporary WORKSPACE_ROOT for its source probe. |
The unfixed candidates above are within the requested same-defect audit scope; they are not unrelated cleanup requests.
## Local fix and verification
Setting WORKSPACE_ROOT to tmp_path, rather than deleting it, is the correct local choice because lib.sh derives its default from the library's location, not HOME or cwd. The new test validates the empty sentinel, unchanged checkout-shim mtime, expected shell workspace and actual shim in the test root.
The exact base _env function was loaded from git and substituted only in memory into the current test module. The new regression failed with "written into the sentinel" and the sentinel shim existed. With head _env it passed and no sentinel shim existed. This negative control changed no repository files.
- Changed file, WORKSPACE_ROOT absent at pytest launch: **286 passed, 4 deselected in 262.11s**; exit 0. Log: /tmp/9bc1f200-unset.log.
- Same file, WORKSPACE_ROOT set to an existing external temporary sentinel: **286 passed, 4 deselected in 262.02s**; exit 0. Log: /tmp/9bc1f200-sentinel.log.
- Both runs used --noconftest, -p no:cacheprovider and separate external basetemp directories, with no plugin/fixture pinning WORKSPACE_ROOT. This tests the helper's fix itself.
- At completion, /private/tmp/9bc1f200-unset-7yqs28zl/sentinel and /private/tmp/9bc1f200-sentinel-otbahzmr/sentinel each contained **zero entries**.
- Supplementary tests: 13 broker-independent issue33_budget cases plus the wrapper mapping contract from test_issue5_phase1_delegation and the G10 mapping contract from test_tier1_unit: **15 passed, 7 deselected in 29.57s**. Log: /tmp/9bc1f200-other.log. Only the two contract tests used a minimal temporary mam_sandbox fixture; this is not a full-suite pass claim for those two files.
- Full broker-dependent suites were not executed in the restricted environment; the orchestrator retains their integration validation.
- Independent negative-control regression and the three omitted-helper probes completed successfully, producing the results detailed above.
- The changed Python file parses on Python 3.9; git diff --check passes. Final git status --porcelain was empty and HEAD/tree still matched the brief.
The four unreaped-owner cases were deselected because this sandbox blocks ps, as independently diagnosed in the earlier review. The brief permits network-free review where broker integration is blocked. Neither these omissions nor MQTT publication restrictions are the reason for NOT PASS.
## Scope and lifecycle
Only tests/test_issue17_item4_decisions.py changed (36 insertions, 2 deletions). Product files, versions and branches are unchanged. All test runs and probes used the designated review path, Python 3.9 and native Bash 3.2; artifacts, test registries, HOME and all sentinel roots were temporary. Inherited control-plane/server variables were scrubbed. No .mam.env was exported, no real agent/server was contacted, and no reviewer source edits were made.
The requested started command was executed first; MQTT exhausted three attempts with Operation not permitted and exited 2. The requested completed command was also executed after saving the report; all three MQTT attempts failed with Operation not permitted and the command exited 2. The report is saved, but successful delivery of started/completed to the broker is not confirmed.
[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-codex-01",
"job_id": "a2716e3b",
"verdict": "PASS",
"transport": "herdr",
"evidence": "review-tree",
"isolation": "worktree",
"verified_at": "2026-10-13T02:56:58Z"
}
@@ -0,0 +1,45 @@
# README 4차 리뷰 — a2716e3b
판정: PASS. 지정된 두 문장의 수정에서 차단할 사실 오류나 영문·한국어 의미 불일치를 발견하지 못했습니다.
## 리뷰 identity 및 범위
- loop: `loop-20261013T025132Z-60f239`
- base: `ae2813773c973daedd2275cb55ad340a682c3fae`
- head: `62da8056680fca939a2aff924037ed48b5f74afa`
- tree: `f549246ffbe2d5e58cb7e33c212a383cd429dce5`
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261013T025132Z-60f239/1`
- HEAD/tree가 브리프와 일치하고 작업 트리가 clean임을 확인했습니다. 이번 커밋만 검토했으며, 두 README에서 각각 41행과 175행만 변경된 것을 확인했습니다.
## 검토 결과
### 1. generate-env.sh 설명 — PASS
`README.md:41`, `README.ko.md:41`은 대상 파일이 없을 때 생성하고, `--force`로 기존 파일을 덮어쓸 때 `.mam.env.bak`을 남긴다고 동일하게 설명합니다.
- `deploy/generate-env.sh:17–18`: 템플릿과 대상 경로를 `.mam.env.example`, `.mam.env`로 지정합니다.
- 54–56행: 대상이 있고 `--force`가 없으면 성공 코드로 종료하며 변경하지 않습니다.
- 59–61행: 대상이 있고 `--force`가 있으면 `cp -p "$DST" "$DST.bak"`으로 백업합니다.
- 64행: 템플릿을 대상으로 복사합니다.
머리 주석 5–7행뿐 아니라 실제 분기를 확인했습니다. 별도 옵션인 `--migrate-legacy`는 이번 문구의 기본 생성/force 설명과 충돌하지 않습니다. 스크립트는 실행하지 않았습니다.
### 2. 테스트 격리 범위 — PASS
`README.md:175`, `README.ko.md:175`은 모든 테스트의 임시 잡 경로와 일부 테스트가 쓰는 임시 HOME/mock herdr를 구별합니다.
- `tests/conftest.py:170–181`: autouse `_isolate_mam_state_dirs`가 테스트별 임시 잡 레지스트리와 감사 로그 경로를 설정합니다.
- 195–207행: autouse 에이전트 환경 정리 자체는 HOME을 바꾸지 않는다고 명시합니다.
- 228–269행: 선택적 `mam_sandbox`가 HOME과 레지스트리를 임시 경로로 설정합니다.
- 312–315행: 별도 `mock_herdr` 픽스처가 `mam_sandbox`에 의존하여 mock 실행 파일을 제공합니다. 샌드박스 사용 자체가 mock 사용을 의미하지 않습니다.
브리프에 제시된 수집 항목 집계는 1708개 중 샌드박스 573개(33.55%), mock 196개(11.48%)이므로 새 표현과 맞습니다. 이 정확한 pytest 수집 수는 재수집하지 않았습니다. 독립적인 AST 정적 확인에서는 테스트 함수 정의 1272개 중 명시적 인자/데코레이터 및 픽스처 의존 경로로 `mam_sandbox` 485개, `mock_herdr` 168개를 확인했습니다. 이 수치는 매개변수화된 pytest 항목 수가 아니며, 클래스/모듈 수준의 간접 지정까지 완전 재현한 집계도 아닙니다. “대부분”이라는 이전 과장을 제거하고 mock 사용 범위를 더 좁힌 수정 방향을 뒷받침하는 보조 확인으로만 사용했습니다.
## 검증 및 제한
- `git diff --check ae28137..HEAD`: 성공.
- 양 문서의 변경 행이 정확히 `[41, 175]`이며 줄 수가 유지됨을 정적 검사로 확인했습니다. 나머지 문장·명령·다이어그램은 이번 커밋에서 바뀌지 않았습니다.
- 두 변경 문장의 조건과 수량 표현이 EN/KO에서 일치합니다.
- 테스트, 설치기, 환경 생성 스크립트, 실제 herdr/에이전트/monitor는 실행하지 않았습니다. 지정 리뷰 트리의 파일만 읽었고 소스·문서·실제 설정 파일을 수정하지 않았습니다. pytest 전체 통과 또는 정확한 수집 수 재현을 주장하지 않습니다.
[VERDICT: PASS]
@@ -0,0 +1,76 @@
# PR #59 2차 Mac 리뷰 — NOT PASS
- Job: `a59e709d`; loop: `loop-20261017T135731Z-e93f0a`
- Base: `26e2c43753169671b600d03274901dbe203988dd`
- Head: `2a8d73825b3832376aeaf96b09548359dc3cb887`
- Tree: `d146f1ac68bcc3485d24fb92edb87264e4d1e8cb`
- 검증 트리: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261017T135731Z-e93f0a/1`
- 환경: macOS, 시스템 Python 3.9.6, bash 3.2, BSD ps. pytest는 지정된 제어 저장소의 `.venv/bin/python`으로 실행.
기존 3건의 결함은 이번 HEAD에서 해결됐고 수정 전 실패도 확인했다. 다만 새 UUID 대소문자 회귀 테스트 2건이 macOS에서 실패한다. 원인은 제품의 UUID 정규화가 아니라 테스트가 UUID 없는 프로세스를 생성하는 데 있다. 현재 HEAD를 이 Mac에서 통과한 상태로 승인할 수 없으므로 NOT PASS다. 수정 범위는 테스트 픽스처이며 재계획은 필요하지 않다.
## 수정 필요: [P2] UUID 명령행 보유자 픽스처가 macOS에서 UUID를 잃음
**근거:** `tests/test_issue31_purge_residual.py:315`의 `subprocess.Popen(["sh", "-c", "sleep 120", conv])`, 이어지는 `:318`의 live 단언. `upper`와 `mixed` 두 매개변수에서 동일하게 실패한다.
macOS의 sh는 마지막 명령을 실행하면서 동일 PID의 명령행을 `sleep 120`으로 바꾼다. `conv`는 셸의 `$0`였으며 sleep 인자로 전달되지 않는다. 앞서 `live.kill()` / `live.wait()`로 세션 파일의 PID도 죽였기 때문에 이 시점에는 세션 파일이나 명령행 어디에도 살아 있는 보유자가 없다. 어댑터가 False를 돌려주는 것은 이 픽스처 상태에서 올바르다.
**재현 절차:**
1. 검증 트리에서 아래 테스트 노드를 실행한다. 임시 HOME/HOME_DIR, CLAUDE_PROJECT_DIR, CLAUDE_CODE_TMPDIR, TMPDIR를 사용하고 브리프의 HERDR/MAM/WORKSPACE 환경 변수들을 제거한다. 테스트의 `home` 픽스처는 CLAUDE_CONFIG_DIR를 제거하여 임시 HOME/.claude를 쓰며, `own_tmp_roots`는 고정 /tmp fallback까지 임시 경로로 대체한다.
```sh
PYTHONDONTWRITEBYTECODE=1 /Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.venv/bin/python -m pytest -p no:cacheprovider --basetemp=<검증트리밖의새임시경로> -q tests/test_issue31_purge_residual.py::test_any_spelling_of_the_uuid_is_the_lowercase_session
```
2. 독립적으로 새 UUID를 생성하고 `Popen(["sh", "-c", "sleep 120", uuid])`를 띄운 뒤 0.1초 후 `ps -p <그 PID> -o args=`를 읽는다. 관측 결과는 `sleep 120`으로 UUID가 없다. 종료 시 생성한 프로세스만 kill/wait한다.
3. 검증 트리 HEAD의 git archive 임시 복사본에서 해당 한 줄만 `Popen([sys.executable, "-c", "import time; time.sleep(120)", conv])`로 대체하고 같은 두 테스트를 실행한다.
**기대/실제:** 원래 테스트는 lowercase UUID를 명령행에 가진 살아 있는 프로세스를 만들고 upper/mixed ID 모두 in-use=True임을 검증해야 한다. 실제로는 UUID 없는 sleep을 만들어 두 건 모두 `:318`에서 실패했다. 임시 복사본의 한 줄 대체 대조군에서는 **2 passed in 2.36s**, 종료 코드 0이었다. 별도 ps 확인에서도 Python 프로세스는 UUID 인자를 유지했다.
**수정 방향:** 셸의 마지막 명령 최적화에 의존하지 않는 프로세스를 생성한다. 이미 import한 `sys.executable`을 사용한 위 Python sleep 픽스처가 최소 수정이다. 생성 PID의 명령행에 UUID가 실제로 남아 있는지 확인하고 기존 finally의 kill/wait 정리는 유지한다. 원본 검증 트리나 creator 트리는 수정하지 않았다.
## 기존 지적의 해결 및 수정 전 실패 검증
현재 테스트 파일을 각 수정 직전 커밋의 격리된 git archive 복사본에 놓고 해당 노드를 실행했다. 테스트 임시 루트 격리 픽스처도 함께 사용했다. 이는 예전 테스트가 아니라 새 회귀 단언이 예전 구현을 실제로 거부하는지 확인하기 위한 것이다.
| 기존 지적 | 현재 구현 및 HEAD 결과 | 수정 전 결과 |
| --- | --- | --- |
| UUID 아닌 `memory`가 공유 폴더를 삭제 | `claude.py:10` 정규 UUID 확인/소문자 변환, `:301` 후보 경로 구성 및 `:368`, `:390` purge/residual 진입 가드. malformed/non-UUID 보존 테스트 통과. upper/mixed의 세션 파일 기반 in-use 및 CLI exit 4 단계도 통과 | `bd09c6a^`에서 `test_claude_rejects_malformed_uuid[memory]` 실패: 기대 []와 달리 memory 관련 경로 3개 삭제 |
| 상위 심볼릭 링크를 통한 범위 밖 삭제 | `claude.py:105`의 `_inside`가 루트 아래 상위 구성요소를 lstat하고 `:336`의 `_owned_paths`가 필터링. tmp key/project key/file-history 링크 뒤 파일 보존, leaf 링크만 삭제, 루트 자체 링크 허용 테스트 통과 | `6e0361c^`에서 `test_claude_purge_never_follows_a_linked_dir_below_a_root` 실패: 링크 뒤 경로 4개 삭제 |
| NaN/inf/과대 정수 startedAt이 죽음으로 판정 | `claude.py:69`의 `_session_file_live`: bool/type/finite/positive 검사 및 float 변환 OverflowError 처리로 판정 불가를 live로 유지. 숫자 경계값 및 CLI 테스트 통과 | `f943b0d^`에서 `[NaN]` 테스트 실패: 살아 있는 PID인데 False. 추가로 `60779ba^`에서 `[10**400]` 테스트 실패: OverflowError |
각 수정 전 실행은 1 failed였으며, 동일 노드들이 현재 HEAD의 관련 모듈 실행에서는 통과했다. 기존 3건을 계속 미해결인 제품 결함으로 보고하는 것은 아니다.
## 검증 결과와 범위
다음 네 모듈을 함께 실행: `tests/test_issue31_purge_residual.py`, `tests/test_issue42_skill_blocks.py`, `tests/test_issue55_agent_homes.py`, `tests/test_a4_adapter_contract.py`.
- 원본 HEAD: **146 passed, 2 failed, 1 skipped in 37.80s**. 위 P2의 upper/mixed 두 건만 실패. 전체 스위트는 실행하지 않았다.
- 임시 복사본의 픽스처 대조군: 위 두 건만 **2 passed**. 이를 원본 HEAD 전체 통과로 합산하지 않는다.
- 변경 Python 파일의 시스템 Python 3.9 구문 검사, 변경 셸 파일의 bash 구문 검사, `git diff --check` 통과.
- 종료 전 HEAD/tree 재확인 일치, 검증 트리 `git status --short` 출력 없음.
- 실제 Claude 실행/실제 세션 purge·stop·in-use는 수행하지 않았다. destructive 테스트는 임시 홈/프로젝트/config/tmp에서만 실행. issue31 외 모듈에도 실제 `/tmp/claude-<uid>`에 접근하지 않도록 별도 테스트 프로세스용 가드를 적용했다.
## 추가 검토 사항
- BSD ps 시작 시각: `LC_ALL=C`, `TZ=UTC0`로 읽어 `calendar.timegm`과 epoch-ms를 비교한다. 실제 임시 sleep 프로세스의 live 판정, 재사용 PID 모형, 판정 불가, UTC/DST 회귀 테스트를 확인했다. startedAt+2초 비교는 유지됐다.
- tmp 레이아웃: 실제 경로는 디렉터리 이름/종류 메타데이터만 확인했다. 이 Mac의 `/tmp`는 `/private/tmp`로 연결되고 실제 per-uid scratchpad는 workspace key 아래 UUID 디렉터리 구조였다. TMPDIR는 별도의 `/var/folders/.../T/`로 해석된다. override/base/per-uid/realpath 중복 제거 테스트 통과.
- 실제 ~/.claude의 projects, file-history, session-env, sessions, jobs 디렉터리 구조는 구현이 가정한 배치와 부합했다. 실제 세션 파일 및 key 내용은 읽지 않았다. 관측된 metadata 수: projects 디렉터리 16개, file-history 19개, session-env 21개, sessions JSON 6개와 key 6개, jobs 디렉터리 2개와 JSON 파일 1개. 이는 구조 확인이지 모든 실제 파일의 소유권을 검증했다는 뜻은 아니다.
- `dd292c1`: 삭제 가능한 `_owned_paths` 대신 전체 `_candidates`로 has_artifacts가 Claude를 선택하므로, 링크 뒤 잔여물만 있어도 auto purge는 Claude로 선택하여 보존 및 residual을 보고한다. `test_purge_cli_reports_a_linked_orphan_it_will_not_remove` 통과.
- 공유 파일/다른 UUID scratchpad/동일 접두사 jobs/다른 세션 metadata 보존 및 실패 후 ownership evidence 유지 검증을 관련 모듈에서 확인했다. key 경로는 보고 시 hex를 마스킹한다.
- `base.py:311`, `__main__.py:186`, `stop_session.sh:292`의 안내 전달을 검토했다. stdout JSON과 stderr 힌트를 분리하고 stop은 in-use gate의 실패를 exit 4로 처리한다. exit 4 및 purge 시 registry 행 삭제 설명이 stop SKILL에 반영됐다. skill code block 관련 검사 통과.
- agy에도 in-use gate가 적용되는 점은 공통 어댑터 계약의 의도된 동작이며 이 자체를 결함으로 판정하지 않았다.
- `test_tests_never_reach_the_real_tmp_root` 및 자식 Python의 fallback 주입 검증 통과. #55 관련 외부 agy 아티팩트를 결함 근거로 사용하지 않았다.
- 상위 경로 링크 검사는 검사 시점의 디렉터리 구조에 관한 보장이다. 동시 파일시스템 변경을 포함한 원자적 삭제 보장까지 검증한 것은 아니다.
## 로컬 증거
임시 증거 루트: `/tmp/review-a59e709d-4yb9c6z4` (macOS 실경로 `/private/tmp/...`).
- `head.log`: 원본 HEAD 네 모듈 실행 결과.
- `before-id.log`, `before-links.log`, `before-nan.log`, `before-overflow.log`: 수정 전 실패 결과.
- `fixture-control.log`: 한 줄 픽스처 대체 대조군의 2건 통과.
- `fixture-control/`: HEAD archive의 임시 대조군. 제품 코드 변경 없음.
[VERDICT: NOT PASS]
@@ -0,0 +1,124 @@
# PR #51 — Codex review, job aa885e48
## 판정: NOT PASS
일반 입력과 안정적인 시스템 시각에서는 기존/새 판정의 비교 검사들이 일치했다. 그러나 새 _sleep이 경과 시간 측정에 wall clock을 사용해, 시스템 시각이 뒤로 이동할 때 두 finalize 재관측 간격이 달라진다. 핵심 계약인 동작 불변을 충족하려면 R1을 수정해야 한다. 수정 범위는 작은 helper와 회귀 검사이며 재계획은 필요하지 않다.
## Identity / 범위
- loop: `loop-20261007T142403Z-10d77f`
- base: `7209750ac7e3ab9a7f6b0abc3670dbe0b1060f88`
- head: `518bedac5d8ab832347bfc6682b146f3201021f1`
- tree: `137b4e46c58b4f27e03dc19e6f9c6b42f574baa3`
- 검증 cwd: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261007T142403Z-10d77f/1`
- base..head 7개 커밋의 변경 전체를 검토했다. 제품 소스 수정·커밋·브랜치 변경은 하지 않았다.
- 검증 중 별도 탐침의 workspace 격리 실수가 있었다. 아래 “검증 과정의 격리 오류”에 별도로 기록한다.
## R1 — P2: wall-clock 역행이 짧은 sleep까지 늘려 완료/heartbeat 재관측을 지연시킨다
**근거:** `.agents/skills/lib_py/delegation/wrapper.py:145–153`은 `end = time.time() + seconds`와 `end - time.time()`으로 sleep의 남은 시간을 계산한다. 이를 broker 재관측(`:208`, 최대 3초)과 디스크 heartbeat 유예(`:236`, 1초)가 모두 사용한다.
기존 bash는 해당 위치에서 `sleep "$left"` 또는 `sleep 1`을 호출한 다음 디스크를 다시 읽는다. 새 구현은 그 짧은 sleep 도중 wall clock이 뒤로 이동하면 되돌아간 시간까지 기다린 후에야 레코드를 읽는다. 잡의 wall-clock deadline이 이동된 시각에 맞춰 판단되는 기존 정책과, 개별 sleep 자체가 늘어나는 것은 별개의 동작이다.
**재현 조건/절차:**
1. `tests/data/issue17_item4_old_decisions.sh`의 실제 옛 `finalize_unfinished`를 실행한다. 이 파일의 함수 9개는 `git show 7209750`의 대응 함수와 바이트 단위로 일치함을 확인했다.
2. 실제 시스템 시각 대신 모의 시각을 사용한다. 시작 wall time=100, START_TIME=100, TIMEOUT=60, IDLE_TIMEOUT=10. 첫 sleep 중 wall clock만 10초 뒤로 이동하고, elapsed time은 정상 증가시킨다.
3. 디스크 레코드는 elapsed 1초부터 completed를 반환하게 한다.
4. 같은 입력으로 새 `finalize_end`를 실행한다. 두 경로 모두 실제 레지스트리/브로커/서버에 접근하지 않는다.
| 경로 | 옛 bash가 completed를 확인한 elapsed | 새 Python이 확인한 elapsed |
| --- | --- | --- |
| broker_unavailable, 3초 재관측 | 3초 | 13초 |
| timeout, 1초 디스크 유예 재관측 | 1초 | 11초 |
**기대:** 기존과 같이 짧은 sleep의 경과 시간 뒤 디스크를 다시 읽고, 그 시점의 wall clock으로 잡 deadline/evidence를 판단한다.
**실제:** 이미 completed인 잡을 새 코드만 추가 10초 기다린다. 시각 보정 폭이 커지면 지연도 그만큼 커진다. 기존 비교 테스트는 안정적인 시각만 사용하므로 이 차이를 잡지 않는다.
**수정 방향:** `_sleep`의 내부 end/remaining만 `time.monotonic()`으로 계산한다. `_now()`, START_TIME+TIMEOUT, 이벤트 timestamp, evidence_until의 wall-clock 정책은 그대로 유지한다. 모의 wall clock 역행 중에도 1초/3초 간격으로 디스크를 재관측하는 비교 회귀 검사를 추가한다.
재현 파일: `/tmp/aa885e48-clock.py`
결과: `/tmp/aa885e48-clock.log`
검증 cwd에서 재실행:
```sh
PYTHONDONTWRITEBYTECODE=1 /Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux-wt/pr-51/.venv/bin/python /tmp/aa885e48-clock.py
```
호스트 시계는 변경하지 않았다. 표의 시간은 모의 elapsed time이다.
## 브리프 검토 항목
### 1. 옛/새 판정과 finalize
- fixture의 mam_int, mam_int_knob, start_stall_watch, _disk_status, _recent_disk_heartbeat, _map_sub_rc, _rc_for, finalize_unfinished, cmd_wait 총 9개 함수를 실제 base와 비교해 일치를 확인했다.
- 일반 finalize 라벨·phase별 cancelled 이유·inject의 agent_blocked·direct만 이벤트 발행·loop/discuss의 이벤트 억제·dispatch token mismatch rc=4 시 후속 이벤트 억제는 비교 검사에서 보존됐다.
- FAIL_CLOSED=0, completed 레코드 조기 종료, terminal error/cancelled, remote/human 분기도 확인했다.
- 기존 스위트 외에 preflight detail의 공백/따옴표/줄바꿈/빈 값 4건과 broker 대기 중 error/cancelled/completed 도착 3건을 옛/새로 직접 비교했다. stdout, stderr, FINAL_JOB_STATUS, registry/publish 호출이 모두 일치했다.
- 단 두 대기의 시계 역행 동작은 R1처럼 달라진다.
### 2. bash ↔ Python 경계
`multi-agent-mux-delegate-job:97–101`의 _decide는 인자를 `"$@"`로 넘긴다. 일반 값의 공백과 따옴표는 유지되며, `:805–831`의 begin 단일 행과 end fd 3 결과 분리는 비교 검사에서 close/publish stdout과 상태가 섞이지 않았다.
Python 경로를 존재하지 않는 경로로 바꾼 추가 탐침에서 exit-code, wait-rc, finalize는 각각 nonzero(이 호스트 bash에서 255/127/127)로 끝나 성공으로 바뀌지 않았다. begin의 command substitution 실패는 read 자체에 가려질 수 있지만, 뒤의 end 실행 실패로 정상 성공 상태를 반환하지 않았다. start_stall_watch의 판정 실패는 기존의 notify-only 감시 기동 실패와 마찬가지로 감시 없이 반환하므로 모든 보조 경로가 job을 중단하는 정책은 아니다.
판정은 wrapper.py로 이동했고 bash에는 실행/대기/프로세스 정리, diagnostic probe, CLI 흐름이 남았다. 새 int 모듈의 직접 실행은 -I로 격리되며 lib.sh 없이 lib_int.sh만 source한 실행도 확인했다.
### 3. watch-pid와 종료
`wrapper.py:122–153,256,267` 부근의 owner 검사는 kill(0) 뒤 ps stat의 Z 접두사를 검사하고, 대기 중과 부작용 직전에 owner가 사라지면 종료한다. 제공된 TERM/미회수 zombie 검사와 추가 KILL 비교를 확인했다.
추가 KILL 탐침은 broker 대기와 timeout 유예 각각에서 옛/새 wrapper만 종료했다. 양쪽 모두 deadline 이후에도 close/publish 호출이 없고 레코드가 그대로였다. 새 코드의 캡처 파이프 종료는 약 0.04초/0.18초, 옛 코드는 약 2.20초/0.39초였다. 옛 코드의 남은 sleep 때문에 최초 자체 탐침의 2초 기준은 너무 짧았으며, 기존 비교 검사의 4.5초 기준으로 바로잡아 재확인했다. 이는 제품 실패로 집계하지 않았다.
실제 polling 구현은 브리프의 0.2초가 아니라 **최대 0.5초 간격**이다. 이 간격 자체에서는 차단 결함을 찾지 않았다. macOS에서 실행 검증했고 Linux는 직접 실행하지 않았으므로 Linux 실측 통과를 주장하지 않는다.
### 4. 정수 규칙
`ints.positive_int`는 이전 runner의 구현과 같은 ASCII 공백/숫자, 선행 0 제거, 유효 9자리 제한, minimum 검사를 유지한다. 옛 bash와 새 adapter의 C/en_US.UTF-8, 유니코드 공백/숫자, 0 허용 여부 및 경계 입력 비교를 확인했다. lib_int.sh의 두 함수는 Python 호출로 얇아졌고, runner도 같은 모듈을 사용한다. minimum/default가 계약 범위의 값일 때 추가 차이를 찾지 못했다.
### 5. 정적 테스트 변경
- issue5: 옛 bash 문자열 대신 호출 연결, Python EXIT_CODES, 상태 매핑, subscriber 상수 일치를 검사한다.
- tier1 G10: broker unavailable 및 디스크 completed 우선 동작을 실행 검사해 기존 문자열 검사보다 의미를 직접 고정한다.
- issue26: Python으로 옮긴 두 환경변수를 shell-only 분류표에서 제거했으며 새 단위/비교 테이블이 해당 정책을 확인한다.
- issue21 L15: 독립 복사 fixture에 ints.py를 같이 복사한다. 기존 diff-only 거부/로컬 허용 assertion은 유지됐고 이 테스트를 별도로 실행했다.
- #42 인벤토리는 근거 줄 번호만 바뀌었다. 정책 분류 변경은 없다.
### 6. 성능
이 호스트에서 정수 판정 100회의 경과 시간은 옛 bash 약 0.0056초, 새 Python adapter 약 4.392초였다. `_decide exit-code completed` 20회의 프로세스 실행 중앙값은 0.0474초(0.0459–0.0493초)였다. 기존 shell-only 판단보다 시작 비용은 분명히 늘었다.
기본 direct 완료 경로는 초기 knob 4회 + 기본 정수 flag 검증 4회 + stall-watch/sub-status/finalize-begin/exit-code 4회로 약 12회의 새 Python 실행이 발생한다. explicit idle-timeout, 조기 subscriber 종료 판정, 미완료 finalize-end, delivery/preflight knob 경로에서 더 늘어난다. 기본 구간만 대략 0.5–0.6초 추가이며 1초 안팎의 짧은 잡에서는 비율이 크다. 반면 긴 finalize 대기에서는 기존의 반복 Python 디스크 probe/date 프로세스를 인프로세스 읽기로 바꿨으므로 비용이 단순히 추가되기만 하는 것은 아니다. 지정 예산/통합 검사 결과를 함께 보았고, 이 측정 자체를 차단 사유로 삼지는 않았다.
### 7. 이식성/검증
실제 macOS, Python 3.9, bash 3.2에서 검증했다. 변경된 Python core는 해당 인터프리터 AST parse 통과, 두 변경 셸 파일 bash -n 통과, git diff --check 통과다.
## 테스트 결과
| 검증 | 결과 |
| --- | --- |
| 브리프의 9개 파일, 지정 Python 3.9 환경 | **604 passed, 1 skipped**, 1005.83초 |
| 수정된 issue21 L15 검사 추가 실행 | **1 passed**, 1.08초 |
| 옛 함수 원본 일치 확인 | 9개 함수 바이트 일치 |
| 추가 정상 입력/늦은 종결 비교 | 7건 옛/새 일치 |
| KILL 시 broker/timeout 두 경로 | 옛/새 모두 close·발행 없음 |
| Python 실행 불가 경계 | 상태/종료 판정 3경로 모두 nonzero |
| 시계 역행 비교 | **두 경로의 재관측 지연 차이 재현** — R1 |
| Python AST / bash -n / diff --check | 통과 |
검증 종료 후 HEAD/tree가 브리프와 일치했고 추적 파일의 `git status --porcelain` 출력은 없었다. 전체 저장소 스위트는 요청대로 다시 실행하지 않았다.
추가 자료:
- 전체 지정 선택 검사: `/tmp/aa885e48-selected.log`
- 변경된 L15 추가 검사: `/tmp/aa885e48-l15.log`
- KILL/실행 실패/정수 성능 탐침: `/tmp/aa885e48-boundary.py`, `/tmp/aa885e48-boundary-v2.log`
- 시계 역행 비교: `/tmp/aa885e48-clock.py`, `/tmp/aa885e48-clock.log`
## 검증 과정의 격리 오류
추가 7개 옛/새 비교를 pytest 밖에서 호출한 첫 탐침에서, 상속된 WORKSPACE_ROOT를 제거하지 않은 제 실수가 있었다. 그 값은 검증 저장소가 아닌 `/Users/godopu16/PuKi/lac/curriculum/00_entry`였고, wrapper source 시 lib.sh의 자동 초기화가 `/Users/godopu16/PuKi/lac/curriculum/00_entry/.mam/shim/herdr` 생성 파일을 다시 썼다. 이 경로 변경은 사용자에게 즉시 알렸다.
해당 탐침의 registry/publish는 stand-in으로 가로챘고 herdr 진단 함수도 stub이었다. 실제 에이전트/서버 작업은 하지 않았다. 다만 그 생성 파일의 실행 전 사본을 확보하지 않았으므로 이전 내용과 동일했는지, 원복됐는지는 주장하지 않는다. 공유 파일을 추측으로 덮어써 복구하지 않았다. 이후 탐침은 HERDR/MAM/MQTT 환경을 제거하고 임시 WORKSPACE_ROOT/HOME을 명시했다. 이 오류는 PR 제품 결함 R1과 별개의 검증 절차 문제다.
[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-codex-01",
"job_id": "b1696427",
"verdict": "PASS",
"transport": "herdr",
"evidence": "review-tree",
"isolation": "worktree",
"verified_at": "2026-10-13T01:21:50Z"
}
@@ -0,0 +1,55 @@
# Job b1696427 — HERDR_SESSION isolation review
판정: PASS. 지정된 두 파일 변경에서 차단할 결함을 발견하지 않았다. 관련 테스트 **84건 모두 통과**했고, 별도 base/head 탐침에서 상속 세션 제거와 fixture 이후의 명시적 설정 보존을 확인했다.
## 검증 identity
- loop: `loop-20261013T010208Z-f90dd4`
- base: `325b056057fcdd717d2c81017fc5ef57183cb4d0`
- head: `db7f4cab9c60ff5db009cf3fc625e9a86a465ef6`
- tree: `f32cabd3a6c8d775c9223509967f1a810f0cf827`
- 검증 cwd: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261013T010208Z-f90dd4/1`
- Python: `/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.venv/bin/python`
HEAD/tree가 지정 identity와 일치하고 `git status --short`는 비어 있었다. `git diff --check 325b056`도 통과했다. 이 커밋만 검토했으며 운영 코드·생성 shim 변경은 없다. 소스 수정, creator 작업 트리 실행, 커밋·push는 하지 않았다.
## 코드 검토
1. `tests/conftest.py:298–301`에서 기존 socket/pane/tab 제거 사이에 `monkeypatch.delenv("HERDR_SESSION", raising=False)`를 추가한 것은 적절하다. 값이 없는 실행도 허용하고, pytest monkeypatch의 기존 복원 수명 주기를 따른다. `.agents/skills/lib.sh:1425–1427`, `lib_py/pane_lock.py:49–50`, `lib_py/loop/watcher.py:64–66`은 이 값을 우선해서 caller server로 읽으므로 sandbox 시작 전에 상속값을 제거해야 pane 안/밖의 기본 분기가 같아진다.
2. `tests/test_herdr_real_server_leak.py:73–88`은 새 변수를 가짜 pane 환경에 넣은 뒤 실제 `mam_sandbox` 이후 남지 않는지 확인한다. 외부 실행 환경을 미리 scrub했더라도 시험이 스스로 값을 주입하므로 추가된 제거 동작을 검증한다.
3. 테스트가 의도적으로 지정하는 값은 보존된다. `test_issue23_24_startup_path.py:31–42`의 helper는 fixture 이후 subprocess 환경을 구성하고 명시적 `env`를 마지막에 적용한다. `:261–266`의 HERDR_SESSION 우선순위 시험을 포함한 모듈이 통과했다. `test_issue50_pane_lock.py:63–80`도 명시적인 환경 mapping으로 caller/lock 식별을 검사한다. 추가 탐침에서도 fixture 이후 설정한 값이 두 helper에 그대로 전달됐다.
4. 확인한 라우팅·범위 변수 `HERDR_SESSION`, `HERDR_SESSION_NAME`, `HERDR_SERVER_NAME`, `HERDR_WORKSPACE_ID`, `HERDR_SOCKET_PATH`, `HERDR_PANE_ID`, `HERDR_TAB_ID`와 설정 경로 `HERDR_CONFIG_PATH`, `XDG_CONFIG_HOME`은 모두 `conftest.py:290–306`에서 제거된다. 실제 fixture setup에 이 9개를 주입한 독립 탐침에서도 head에는 하나도 남지 않았다. 검사한 repository의 caller/scope/config 경로에서 추가 누락 라우팅 변수는 찾지 못했다. 이는 알려진 경로의 검증이며 임의의 미래 herdr 환경변수까지 일괄 제거한다는 의미는 아니다.
`mam_sandbox`를 사용하지 않는 테스트의 환경 계약은 이 두 파일 변경으로 바뀌지 않는다. 예를 들어 실제 서버용 `test_issue50_dispatch_herdr.py`는 모든 HERDR_*를 별도로 제거하고, `test_issue45_restore.py`의 Rig도 HERDR_SESSION을 포함한 scope 변수를 자체 제거한다. 이 실제 서버 모듈은 이번 리뷰에서 실행하지 않았다.
## 실행 검증
안전 launcher `/tmp/b1696427-review/run.py`를 사용해 아래 관련 모듈만 실행했다.
```text
env -u HERDR_SOCKET_PATH -u HERDR_PANE_ID -u HERDR_TAB_ID -u HERDR_WORKSPACE_ID -u HERDR_SESSION_NAME -u HERDR_SESSION -u WORKSPACE_ROOT -u MAM_ENV_FILE <Python> /tmp/b1696427-review/run.py tests/test_herdr_real_server_leak.py tests/test_issue50_pane_lock.py tests/test_issue23_24_startup_path.py
```
- **84 passed in 195.48s**, exit 0. skip/failure 없음.
- 로그: `/tmp/b1696427-review/tests.log`
- basetemp: `/tmp/b1696427-mxmhd4h3/bt` — 검증 트리 밖이며 이름에 shim 없음.
- `PYTHONDONTWRITEBYTECODE=1`, `-p no:cacheprovider`, 일회용 HOME, 지정 AGENT_PYTHON_BIN 사용.
- launcher는 상속 HERDR_*/MAM_*/MQTT_*/SKS_* 및 제어·에이전트 경로 환경을 추가로 제거했다. 실제 설치 herdr에 도달하기 전에 기록 후 exit 97로 중단하는 fallback 디코이를 PATH에 두었다. **fallback 호출 기록 없음.** 테스트별 mock과 suite의 로컬 broker를 사용했다.
- 전체 스위트, 실제 herdr, 운영 모니터·서버는 실행하지 않았다. `.mam.env` export/출력이나 실제 사용자 에이전트 홈 변경도 하지 않았다.
## 독립 base/head 탐침
산출물: `/tmp/b1696427-review/probe.py`, `/tmp/b1696427-review/probe.log`.
재현은 위와 같은 `env -u ...` 및 `PYTHONDONTWRITEBYTECODE=1`로 지정 Python에서 probe.py를 실행한다. 검증 트리의 git base와 head에서 `mam_sandbox` 함수 AST를 추출해 pytest decorator만 제거하고, 실제 함수 본문을 별도 `/tmp` 디렉터리에 실행했다. `pytest.MonkeyPatch`를 사용하고, 이 setup에서 시작하는 데몬이 없으므로 등록되는 reaper finalizer는 실행하지 않았다. 서버를 실행하는 코드는 호출하지 않았다.
절차와 결과:
1. fixture 전에 위 9개 변수에 안전한 sentinel 값을 주입한다.
2. base fixture 실행 후 남은 변수는 `HERDR_SESSION` 하나이고 값은 `caller-sentinel`이었다. 실제 Python `pane_lock.caller_server`와 검증 트리에서 추출한 셸 `_herdr_caller_server` 모두 `caller-sentinel`을 반환했다.
3. head fixture 실행 후 남은 변수는 없었다. 두 helper 모두 빈 값을 반환했다. 기대한 기본 호출자 분기 복원이 확인됐다.
4. 각 fixture 실행 뒤 `HERDR_SESSION=chosen-by-test`를 설정하면 두 helper 모두 `chosen-by-test`를 반환했다. 명시적 테스트 설정은 제거되지 않는다.
탐침 exit 0, 모든 assertion 통과. 임시 root는 `/tmp/b1696427-probe-6o1gbryi`였다. 이전 리뷰의 결과를 현재 커밋 검증으로 대체하지 않고 이번 identity에서 새로 실행했다.
[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-codex-01",
"job_id": "b8dbd280",
"verdict": "PASS",
"transport": "herdr",
"evidence": "review-tree",
"isolation": "worktree",
"verified_at": "2026-10-05T09:05:24Z"
}
@@ -0,0 +1,93 @@
# PR #48 — 3회차 Codex 델타 리뷰
## 판정: PASS
이전 R1–R3이 해결됐다. 고정 MINIMAL 설정을 검사 실행 불가 시 경고와 함께 사용하는 R1의 대안도 이번 지원 범위에서 수용한다. 차단 결함은 발견하지 않았다. 문서 표현 정리 1건은 비차단 의견이다.
## 검증 identity / 범위
- job: `b8dbd280`
- loop: `loop-20261005T085016Z-c0f6eb`
- base: `e8d9151226d8139c7b5688e83d035a3a747aeb2d`
- head: `ab93776d6d1b8ca534c14b6be53f61dc3a0fa2a9`
- tree: `f49ffdc8e95dc316f752fd81b2d55a0efddf1a12`
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261005T085016Z-c0f6eb/1`
- `git diff e8d9151..HEAD`의 제품 모듈 2개와 테스트 파일 2개만 델타 리뷰했다. 검증 전후 HEAD/tree 일치, 작업 트리 clean.
- 소스 수정·커밋·브랜치 변경 없음. 실행 중 사용자 herdr 서버·설정·소켓, .mam.env를 사용하거나 변경하지 않았다.
## 이전 지적 종료 표
| 지적 | 현재 근거 | 검증 및 판단 |
| --- | --- | --- |
| R1: 검사 timeout/OSError에도 스키마 오류 병합 파일 반환 | `.agents/skills/lib_py/herdr_config.py:247–258` | 검사 불가한 병합 파일은 폐기하고 MINIMAL로 전환한다. timeout/OSError 회귀 검사와 이전 독립 탐침 통과. **해결**, 아래 대안 판단 참조. |
| R2: 0/False/빈 문자열/음수의 동등 비교로 pane 닫기 | `.agents/skills/lib_py/herdr_restore.py:44–53` | 양쪽 값이 bool이 아닌 양의 정수일 때만 비교한다. 비정상·누락 응답에서 닫기 없음, 정상 셸은 닫고 busy pane은 유지한다. 이전 비정상 PID 탐침 4건도 통과. **해결**. |
| R3: NaN 때문에 무관한 정상 설정까지 유실 | `.agents/skills/lib_py/herdr_config.py:140–149,171` | 재귀 비교에서 NaN끼리 동등하게 처리한다. 중첩/배열 NaN 및 기존 UI 설정 유지 검사, 이전 독립 탐침 통과. **해결**. |
## R1: MINIMAL을 검사 실행 불가 시 사용하는 선택
이전 보고서에서는 MINIMAL 검사도 실패하면 경로를 반환하지 않는 방안을 제안했다. 이번 구현은 그 제안을 그대로 따르지는 않지만, 원래 결함의 핵심인 **검사되지 않은 사용자 병합 내용의 사용**을 막으며 #45의 복원 비활성화 목적을 더 잘 보존하므로 수용한다.
- MINIMAL은 사용자 스키마 값과 분리된 고정 `[session]\nresume_agents_on_restore = false\n` 본문이다.
- 실제 herdr 설정 검사와 실서버 복원 검사가 이 설정을 받아들이고 복원을 끄는 것을 검증했다.
- 첫 검사 timeout 뒤 두 번째 MINIMAL 검사가 성공하는 경로를 별도 주입 검사했다. 호출 2회, 반환 파일은 MINIMAL이었다.
- 두 번 모두 검사 실행이 불가능한 경우에도 병합 내용은 남지 않고, `using the fixed minimal config unchecked` 경고로 미검증 상태를 밝힌다.
- 검사기를 실행했고 파일을 명시적으로 거부한 경우는 예외와 다르다. 병합/MINIMAL 양쪽에 명시적 거부 응답을 주입하면 여전히 경로를 반환하지 않는다(`:259–263`). 이 흐름도 별도로 확인했다.
- 아무 파일도 넘기지 않는 기존 bootstrap 경로는 복원이 켜진 기본값으로 시작할 수 있으므로, 알려진 고정 설정을 넘기는 선택에는 구체적인 근거가 있다.
이 판단은 PR이 대상으로 삼은 herdr의 알려진 설정 스키마에 대한 것이다. “실행하지 못한 검사가 통과했다”는 의미는 아니다.
알 수 없는 nonzero 종료의 판정은 이번 커밋에서 바뀌지 않았다. herdr가 무해한 unknown-section 경고에도 nonzero를 반환하므로, 모든 nonzero를 파일 전체 거부로 취급하는 변경을 R1 종료의 조건으로 요구하지 않는다.
## _same_doc 의미 확인
**이전 일반 `==`와 완전히 동일하지 않다.** `:149`가 동일한 타입을 요구하므로 NaN 처리 외에 int/float/bool 교차 동등성도 더 엄격해졌다. 이는 TOML 재직렬화의 타입 보존 검증에 적절하며, 현재 writer가 유지하는 유효 값의 정상 왕복을 깨뜨리는 사례는 찾지 못했다.
| 비교 | 이전 == | 현재 _same_doc | 판단 |
| --- | --- | --- | --- |
| `1` / `1.0` | true | false | int/float 타입 변경을 감지 |
| `True` / `1`, `False` / `0.0` | true | false | bool/숫자 혼동 방지 |
| 같은 타입의 int/bool/string | 기존 값 비교 | 동일 | 유지 |
| `nan` / `nan` | false | true | 의도한 수정 |
| `nan` / 일반 숫자 | false | false | 유지 |
| `0.0` / `-0.0`, 같은 infinity | true | true | 유지 |
| 같은 datetime, 같은 순간의 다른 UTC offset | true | true | 유지 |
| date / datetime | false | false | 유지 |
| 배열 순서·길이 차이 | false | false | 유지 |
| dict 키 차이 | false | false | 유지 |
| dict/list 안의 새로 생성한 NaN들 | false | true | 재귀 수정 |
datetime/time offset, 중첩 NaN, 배열 길이·순서 등을 포함한 독립 비교 18건을 실행해 기대 결과를 확인했다. 이 비교는 파서가 반환하는 TOML 자료형을 대상으로 하며 임의 사용자 정의 Python 객체를 위한 범용 동등 비교를 요구하지 않는다.
## 실행 결과
모든 실행 cwd는 위 검증 경로였다. Python bytecode 및 pytest cache를 끄고 basetemp는 검증 경로 밖에 두었다.
| 검사 | 결과 |
| --- | --- |
| 지정 Python 3.9: herdr_config, restore_unit, 실제 restore, issue9_server_env | **53 passed, 34 skipped**, 72.81초 |
| 브리프의 Python 3.11: herdr_config, restore_unit | **67 passed**, 0.40초 |
| 이전 리뷰의 독립 결함 탐침 파일 그대로 현재 head에서 실행 | **6 passed**, 0.02초 (이전 head에서는 6 failed) |
| _same_doc 독립 비교 | **18건 통과** |
| 검사 timeout→MINIMAL 성공 / 양쪽 명시적 거부 | **2개 흐름 통과** |
| git diff --check 및 변경 Python 모듈 AST parse | 통과 |
3.9의 34 skip은 tomllib이 필요한 검사다. 이를 보완해 3.11에서 해당 두 파일 전체 67건을 실행했다. 실제 herdr 복원 및 양성 대조 테스트도 3.9 실행에 포함되어 통과했다.
- 3.9 로그: `/tmp/b8dbd280-selected39.log`
- 3.11 로그: `/tmp/b8dbd280-selected311.log`
- 이전 탐침 파일: `/tmp/714c5189_extra_probes.py`
- 이전 탐침 재실행: `PYTHONDONTWRITEBYTECODE=1 /tmp/270e8aaa-py311/bin/python -m pytest -p no:cacheprovider -q --basetemp=/tmp/b8dbd280-prior-probes /tmp/714c5189_extra_probes.py`
실행 wrapper에서 상속된 HERDR_*를 제거하고 버리는 HOME/XDG_CONFIG_HOME을 사용했다. 실제 테스트 세션은 uuid4 기반 `mamt45-84511-*`였으며, 종료 후 해당 서버 프로세스 및 해당 세션을 담은 임시 루트가 남아 있지 않음을 확인했다.
## 비차단 의견 C1 — 문서의 검사 필수 표현을 예외와 맞추기
`.agents/skills/lib_py/herdr_config.py:23–26,239–240`과 `VERSIONS.md:49`에는 결과가 반드시 herdr 검사에서 승인된다는 표현이 남아 있다. 이번 `:247–258`의 고정 MINIMAL 예외는 코드 주석과 런타임 경고로 설명되지만 이 문서 표현에는 반영되지 않았다.
“사용자 설정을 병합한 파일은 검사 불가 시 사용하지 않으며, 검사기를 실행할 수 없는 경우 고정 최소 설정을 경고와 함께 사용한다”는 예외를 문서와 docstring에 추가하면 정확해진다. 제품의 실패 처리와 경고는 충분히 명시적이므로 이 문서 보완을 이번 PASS의 차단 조건으로 삼지 않는다.
## 범위
변경은 기존 세 지적의 설정 검사 실패 처리, PID 검증, NaN 재파싱 비교와 회귀 테스트에 한정된다. bash 정책 추가, 버전 변경, 인벤토리 분류 변경, 승인되지 않은 already-running 검사나 무명 에이전트 종료 기능은 없다. 재계획은 필요하지 않다.
[VERDICT: PASS]
@@ -0,0 +1,114 @@
# Review — c20f3d1f
## Verdict
PASS. 이번 누적 변경에서 차단할 결함을 찾지 못했다. 이전 리뷰 9bc1f200의 R1(상속 환경으로 lib.sh를 source하는 다른 테스트 누락), R2(onboard의 import 시점 WORKSPACE_ROOT 캐시)는 수정되었다. 이 판정은 아래 정적 검토와 제한 환경 실행에 근거하며 전체 통합 스위트 통과를 뜻하지 않는다. 브리프가 허용한 대로 소켓/프로세스 조회 제한 자체를 NOT PASS 사유로 삼지 않았다.
## Review identity / scope
- loop: `loop-20261008T133858Z-9e7f5b`
- base: `6b00b6e06f55299cb60f156ccef09ede4bae5bdd`
- head: `8b47cc76eed4582b1848b2eec529523a61497320`
- tree: `bfe5fcbbefbde9562334515b4a9803bee96f84fd`
- 검증 cwd: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261008T133858Z-9e7f5b/2`
- 실행 Python: `/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux-wt/issue17-wsroot/.venv/bin/python` (Python 3.9).
- creator 작업 트리에서 실행하거나 소스를 수정하지 않았다. 검증 전후 identity가 일치하고 최종 `git status --porcelain`은 비어 있다. `git diff --check 6b00b6e HEAD` 통과. 변경 15개 Python 파일 AST 파싱 통과. 제품 코드 `.agents/skills`의 base..head diff는 없다.
## 요구사항 및 이전 지적 확인
1. `tests/test_issue17_item4_decisions.py:224`의 `_env`가 WORKSPACE_ROOT를 제외하고 테스트 tmp_path로 지정한다. 단순 unset이면 lib.sh가 checkout을 기본값으로 삼으므로 명시적 임시 경로가 맞다. 마지막 `extra` 적용은 테스트가 의도한 override를 유지한다.
2. `tests/conftest.py:180`의 opt-in `own_workspace_root`와 10개 모듈의 usefixtures 표시는 기존 mam_sandbox를 쓰지 않던 셸 테스트를 격리한다. 전역 autouse가 아니므로 WORKSPACE_ROOT 기본값을 검사하는 다른 Python 테스트를 일괄 변경하지 않는다.
3. 실제 conftest fixture를 등록한 별도 탐침에서 module fixture가 tmp_path/.mam-test-state/workspace를 설정하고, 명시적인 mam_sandbox가 그 뒤 자신의 root로 덮어쓰는 것을 각각 확인했다. 테스트 본문 monkeypatch도 이후 적용된다.
4. `tests/test_orc_onboard.py:19`에서 캐시의 WORKSPACE_ROOT를 제거하고 `:27`의 `_scrubbed_env`가 호출 시점의 root를 적용한다. run_onboard/run_find_uuid/run_verify_uuid/dump_state 및 inline 호출이 이 경로로 전환되었다. import 시점 sentinel을 이후 fixture root로 바꾸는 W2 탐침이 통과했다.
5. `tests/test_workspace_scope.py:31`은 unittest 경로를 자신의 ws_dir로 고정한다. `tests/test_issue21_worktree_prereqs.py:544`의 `_mutate`도 자신의 tmp root를 사용한다. 후자는 실제 mam_sandbox와 별도 sentinel을 사용한 탐침으로 검증했다.
6. 새 `tests/test_issue17_workspace_root_isolation.py:54` W1은 checkout 복사본과 real conftest를 사용하는 nested pytest로 이전 fallback-to-checkout 문제를 검사한다. nested pytest의 성공/통과 수를 요구하므로 환경 실패를 격리 성공으로 오인하지 않는다. W1 자체는 로컬 브로커가 필요하여 이 샌드박스에서 완주하지 않았으며 아래 표의 실행 결과에 포함하지 않았다.
7. 추가된 코드는 Python 3.9 AST 파싱 및 실제 실행을 통과했다. Bash 제품 로직이나 Bash 버전 의존 문법은 변경하지 않았다.
## 실행 결과와 한계
실험 전 HERDR_*, MAM_*, MQTT_*, SKS_*, WORKSPACE_ROOT 및 control-plane 경로 환경을 제거하고 HOME을 테스트별 임시 디렉터리로 설정했다. 모든 pytest 실행은 bytecode/cache 쓰기를 끄고 별도 임시 basetemp를 썼다. 실제 .mam.env 값을 읽어 export하거나 출력하지 않았다.
정상 conftest의 session-autouse local MQTT broker는 sandbox의 socket bind 제한 때문에 사용할 수 없다. `/tmp/c20f3d1f-run.py`는 검증 트리의 실제 conftest를 import하고 **local_mqtt_broker fixture만 메모리에서 제외**한 뒤 `pytest.main(..., --noconftest, plugins=[module])`를 실행한다. 나머지 fixture, 환경 처리 hook, 상태 guard는 유지했다. 이는 수정하지 않은 전체 스위트 실행과 다르다.
| 실행 | 결과 | sentinel |
|---|---|---|
| WORKSPACE_ROOT unset, 주 검증 묶음 | 135 passed, 21 failed, 107.10s | 0 entries |
| WORKSPACE_ROOT=기존 sentinel, 동일 묶음 | 135 passed, 21 failed, 107.53s | 0 entries |
| 추가 검증 묶음, unset | 84 passed, 69.36s | 0 entries |
| fixture 순서 및 `_mutate` 직접 탐침 | 3 passed, 0.24s | 0 entries |
주 검증 묶음은 다음 파일 전체와 두 회귀 node다:
- test_b19_headless_reconcile_fixes, test_b8_send_keys_verification, test_loop_cli, test_version_consistency, test_workspace_scope, test_orc_onboard, test_o3_scoped_guard, test_issue5_phase3_loop, test_issue5_phase4_runtime, test_issue25_plan_reuse, test_issue33_budget.
- test_issue17_workspace_root_isolation::test_w2_onboard_does_not_carry_its_import_time_root.
- test_issue17_item4_decisions::test_c_helpers_write_nothing_to_an_inherited_workspace.
두 환경의 실패 21건은 동일하다:
- O3 guard 12건: `ps` 실행 시 PermissionError / Operation not permitted.
- onboard O18/O28/O29 3건: 프로세스 command/parent를 `ps`로 읽는 autodetection 불가로 exit 3. `orc_onboard.sh:108,185`의 조회와 해당 환경 의존 테스트 경로를 확인했다. explicit UUID 및 나머지 onboard 검증은 통과했다.
- issue33 budget 6건: local_broker_env()가 실제 로컬 브로커를 요구하여 실패. 제외한 broker fixture에 따른 실행 제약이다. 나머지 budget 13건은 통과했다.
추가 묶음 84건에는 test_tier1_unit 전체, phase1 delegation C01/C02/C04/C05 및 event/exit mapping 두 contract node, fail_closed의 T1-25와 T3-1/T3-2, worktree_prereqs T2-8이 포함된다. 해당 phase1 node의 파라미터 케이스를 모두 포함한다.
따라서 수정 파일 전체와 phase1 delegation 전체의 두 환경 통합 실행을 모두 통과했다고 주장하지 않는다. 특히 W1 nested pytest와 실제 broker/process-inspection 경로의 전체 확인은 브리프에 따라 오케스트레이터의 제한 없는 실행으로 남긴다.
임시 로그 및 재현 드라이버:
- `/tmp/c20f3d1f-run.py`
- `/tmp/c20f3d1f-unset.log`
- `/tmp/c20f3d1f-sentinel.log`
- `/tmp/c20f3d1f-extra.log`
- `/tmp/c20f3d1f-probe-tests/test_order.py`
실행 후 확인한 sentinel:
- `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/c20f3d1f-unset-ffhmus4t/sentinel`
- `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/c20f3d1f-sentinel-bi_df5cb/sentinel`
모두 비어 있으며 검증 checkout의 `.mam` 아래에도 파일/하위 디렉터리가 없다. 별도 탐침의 첫 시도는 /tmp 상위 수집 중 접근 제한 디렉터리 때문에 collection 오류였고, 전용 probe 디렉터리와 rootdir를 지정한 재실행에서 3건 통과했다. 소스 변경은 하지 않았다.
## 회귀 negative control
`/tmp/c20f3d1f-negative.py`로 현재 검증 트리에서 base 코드를 `git show`로 읽어 메모리에만 로드했다. 실제 저장소/작업 트리를 되돌리지 않았다.
| 탐침 | 수정 전 base | 현재 head |
|---|---|---|
| W2와 같은 import-time onboard root 시나리오 | sentinel 3 entries (`.mam/shim/herdr`), own shim 없음 | sentinel 0 entries, own shim 존재 |
| 현재 decisions 회귀 테스트에 base `_env`만 교체 | `AssertionError: written into the sentinel` | 동일 테스트 통과 |
절차: 기존 빈 sentinel을 WORKSPACE_ROOT로 설정 → onboard 모듈 import → 환경을 fixture-ws로 변경 → run_find_uuid(fixture-ws, 'claude'). 두 번째 탐침은 현재 회귀 함수에 base `_env` 함수 AST를 넣고 실행한 다음 head 함수로 복원해 재실행했다. 기대 결과는 sentinel 불변 및 자기 root에만 shim 생성이다. 현재 코드는 두 시나리오 모두 기대와 일치한다.
산출물: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/c20f3d1f-negative-opdzrib1`.
## 같은 결함 후보 재조사 / 수정하지 않은 후보
`rg`로 환경 복사, lib.sh source, wrapper/run_loop 실행 지점을 다시 조사하고 fixture 또는 명시적 root 경로를 대조했다. 아래는 변경하지 않은 주요 후보의 제외 근거다. 단순히 환경을 복사한다는 이유만으로 안전하다고 판단하지 않았다.
| 후보 파일:라인 | 제외 근거 |
|---|---|
| tests/test_issue14_delivery_ack.py:33 | `_env(cwd)`가 WORKSPACE_ROOT=cwd를 지정한다. |
| tests/test_issue18_liveness_contract.py:54 | `_env(cwd)`가 WORKSPACE_ROOT=cwd를 지정한다. |
| tests/test_issue33_budget.py:43 | 독립 `_env`가 테스트 root를 지정한다. |
| tests/test_issue34_job_tree.py:50,587 | `_env(home)`은 root를 고정하고 `_run_lib` 호출은 mam_sandbox를 사용한다. |
| tests/test_issue36_prompt_transport.py:47,532 | `_env(home)` 및 shell-loop 실행이 명시적인 임시 control root를 쓴다. |
| tests/test_issue26_ints.py:124 | HOME과 WORKSPACE_ROOT를 같은 테스트 home으로 설정한다. |
| tests/test_c1_tui_readiness.py:12 | helper 내부 mkdtemp를 WORKSPACE_ROOT로 설정한 뒤 source한다. |
| tests/test_layout.py:310 | generated-shim 테스트가 임시 ws_dir를 source 전에 export한다. |
| tests/test_issue6_turn_handshake.py:24,43 | cwd를 준 helper는 root/home을 그 경로로 바꾸며 mock_herdr 경로는 mam_sandbox에 의존한다. |
| tests/test_issue5_phase1_delegation.py:49 | lib helper 호출은 mam_sandbox에서 실행되고 root는 fixture의 임시 경로다. Python-only 계약 검사는 shim bootstrap이 없다. |
| tests/test_tier1_unit.py:14,464 | 셸 helper 호출은 mam_sandbox 사용. :464의 별도 환경은 Python workspace_uuid만 실행하며 lib.sh를 source하지 않는다. |
| tests/test_uuid_target.py:11 | mutation helper와 셸 호출이 mam_sandbox를 사용한다. |
| tests/test_o2_race_free_lock.py:16 | 상태 helper 호출은 mam_sandbox fixture를 사용한다. |
| tests/test_issue20_state_isolation.py:150,199 | 격리 회귀 목적으로 자체 임시 repo/root를 명시한다. |
| tests/test_b7_diff_untracked.py:11 | diff_collect는 lib.sh shim bootstrap 경로가 아니다. |
| tests/test_issue8_update_state.py:24 | 설치/업데이트 대상은 별도 임시 workspace이며 lib.sh source 결함과 다르다. |
| tests/test_issue5_phase2a_loop.py:754 | 해당 source 문자열은 static guard 검사용 텍스트이며 실제 셸 실행이 아니다. |
이번에 수정된 누락 후보(b19/b8/loop CLI/phase3/phase4/O3/version/issue25/fail_closed)는 own_workspace_root에 opt-in했다. unittest workspace_scope와 worktree_prereqs mutation은 직접 고정하고, import-time 캐시를 쓰던 onboard는 runtime root 적용으로 별도 해결했다. 추가 동일 결함은 확인하지 못했다.
## Lifecycle
요청한 started 명령을 먼저 실행했으나 MQTT 연결이 sandbox의 Operation not permitted로 실패하여 exit 2였다. 리뷰 결과 파일을 쓴 뒤 요청된 completed 명령도 실행했다. 3회 연결 시도 후 같은 Operation not permitted로 exit 2를 반환했다. 따라서 MQTT 전달 성공은 확인하지 못했다. 보고서의 PASS와 MQTT 전달 성공 여부는 별개다.
[VERDICT: PASS]
@@ -0,0 +1,120 @@
# Issue #60 2차 리뷰 — NOT PASS
이전 자식 legacy 데이터 누락은 실제 OpenCode 2.0.32에서도 해결됐다. 새 기능의 행동 테스트와 수정 전 실패도 확인했다. 그러나 새 XDG_DATA_HOME 읽기가 테스트의 환경 격리 목록에 반영되지 않아 원본 HEAD 검사 1건이 실패하며, 실제로 sandbox 밖 DB 경로를 선택하는 회귀가 있다. 국소 수정이 필요하고 재계획은 필요하지 않다.
## Identity
- Job: `c4e96018`; loop: `loop-20261018T064723Z-79133f`
- Base: `74333f87d0c02888c2b5b281e912d97e74348c01`
- Head: `cb5f439cce41a2741a6b9974a308a765e50f00ad`
- Tree: `cfec95403cbde10a6340b9007e1246c83be461cd`
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261018T064723Z-79133f/1`
시작/종료의 HEAD/tree가 일치하고 검증 트리는 clean이다. creator 트리에서 실행·수정하지 않았다. 제품 원본과 원본 테스트는 수정하지 않았다. 아래 대조군만 검증 트리의 별도 git archive 복사본에서 변경했다. 판정은 base..head 누적 변경을 대상으로 한다.
## 수정 필요 — P1: 상속된 XDG_DATA_HOME이 테스트 sandbox 밖을 가리킴
**근거:** `.agents/skills/lib_py/agents/adapters/opencode.py:90`~`:93`은 HOME과 ctx.home_dir가 같으면 ambient XDG_DATA_HOME을 반환하도록 새로 변경됐다. 그런데 `tests/_agent_home_guard.py`의 `DROPPED_VARS`에는 XDG_DATA_HOME이 없고, `tests/conftest.py::_isolate_agent_homes`는 그 목록만 제거한다. `mam_sandbox`는 HOME/HOME_DIR를 임시 홈으로 바꾸지만 XDG_DATA_HOME은 제거하지 않는다. 따라서 이 시점에 own=True가 되면서 호출자의 외부 XDG data root가 그대로 테스트에 유입된다. `watch_dirs()` 역시 opencode의 기본 HOME/.local/share 경로만 감시한다.
**원본 HEAD에서 관측:** `tests/test_issue55_agent_homes.py:61`의 `test_l1_every_path_variable_the_code_reads_is_accounted_for`가 다음 값으로 실패했다.
```text
path variables the adapters/lib.sh read but conftest._isolate_agent_homes does not drop: ['XDG_DATA_HOME']
```
이는 새 파일 생성에 대한 외부 에이전트 활동 오탐이 아니라, 소스 코드가 읽는 변수와 격리 목록의 결정적 불일치다. 테스트 프로세스 시작 전에 XDG를 unset한 이번 리뷰 환경에서도 목록 검사는 실패한다.
**안전한 재현 절차:**
1. 검증 HEAD 복사본에 `mam_sandbox` fixture를 사용하는 짧은 pytest 테스트를 둔다.
2. pytest 호출자의 HOME은 임시 H, XDG_DATA_HOME은 별개의 임시 D로 설정한다. 실제 사용자 경로는 사용하지 않는다.
3. 테스트 안에서 OpenCode 어댑터의 `_bin`을 None으로 대체해 실제 CLI 실행을 막고, `DiscoveryContext(workspace=sandbox, agent_name='opencode', home_dir=sandbox)`로 `_resolve_db`를 호출한다.
4. 결과가 sandbox 내부인지 검사한다.
**기대:** 각 테스트가 명시적으로 설정하지 않은 ambient XDG는 격리되어 `<sandbox>/.local/share/opencode/opencode.db`가 선택돼야 한다.
**실제:**
```text
sandbox: /private/tmp/review-c4e96018-i02oz_61/pytest-isolation/test_inherited_data_home_does_0
resolved_db: /tmp/review-c4e96018-i02oz_61/ambient-data/opencode/opencode.db
```
추가 탐침은 **1 failed in 0.09s**. 실제 파일 쓰기나 삭제를 하지 않고 경로 선택만 확인했다. 일반 개발자 환경에서 D가 실제 XDG 디렉터리라면 어댑터/테스트가 그 DB를 읽거나 변경할 수 있으므로, 단순한 목록 테스트의 기대값 갱신으로 넘길 문제는 아니다. 실제 사용자 데이터 훼손이 관측됐다는 뜻은 아니다.
**수정 방향:** `DROPPED_VARS`에 XDG_DATA_HOME을 추가하여 테스트마다 제거하고, XDG 동작을 검증하는 테스트만 명시적으로 임시 XDG를 설정하게 한다. 세션 guard의 ambient 변수 기록 및 `watch_dirs()`도 새 data-root 계약을 반영하여 실제 XDG 경로를 감시하게 한다. `LEFT_ALONE`에만 추가하거나 목록 단언을 약화하면 sandbox 경로 문제는 남는다.
**대조군:** 임시 복사본에서 DROPPED_VARS에 XDG_DATA_HOME만 추가하자 위 경로 탐침과 기존 L1 검사 두 건이 **2 passed in 0.09s**로 바뀌었다. 이는 격리 누락 원인의 확인이며, 원본 수정이나 전체 검증 완료를 의미하지 않는다. 감시 경로 보완도 함께 필요하다.
## 이전 지적 및 이번 변경 확인
### 자식 legacy 데이터 삭제와 residual
`purge_artifacts`가 CLI 호출 전에 `_descendants`로 session_v2와 legacy session의 parent_id를 따라 모든 깊이의 ID를 모은다. 중복/순환은 found와 root 비교로 제한한다. legacy message/messages/part/parts/todo/session_share/session/sessions를 해당 ID 집합에 대해 삭제한다. 관련 없는 세션은 집합에 넣지 않는다.
이전 리뷰의 실제 재현을 새로운 임시 홈에서 반복했다. 실제 2.0.32 standalone API로 부모 P, 자식 C, 무관한 O를 생성하고, 임시 DB에 P/C/O의 legacy session/message를 추가한 뒤 현재 어댑터로 P를 purge했다.
- session_v2와 legacy session 모두 **O만 남았다**.
- P와 C의 residual은 각각 `[]`였다.
- purged는 부모/자식 네이티브 삭제와 `and 1 child session(s)` legacy 정리를 보고했다.
- 원래 차단 지적이었던 C의 legacy 행 생존은 재현되지 않았다.
새 테스트는 손자 포함 삭제, 무관한 세션 보존, 삭제 실패 시 `[child session ...]` residual도 확인한다. todo의 변경된 계약은 issue31 테스트가 삭제됨을 단언하며 통과했다. session_share도 구현의 명시적 legacy 집합에 포함됐다.
### 서비스 보유자 / XDG / resident / drift C
- 공유 DB에 대한 서비스 보유자 제외, 일반 보유자 및 UUID 명령행 보유자 유지 테스트 통과.
- 호출자 자신의 HOME에서 XDG_DATA_HOME 존중, 다른 HOME에서는 그 홈의 기본 data root 사용 테스트 통과. 제품 경로 계약은 의도대로 바뀌었으나 위 테스트 격리 갱신이 누락됐다.
- resident_children=('opencode',) 테스트 통과. standalone 서버 자체는 idle 판단에서 제외하지만 그 아래 작업 프로세스는 계속 탐색하여 작업으로 본다.
- `test_comp_opencode_create_and_reconcile_and_stop` 통과. 더 최신 자식이 있어도 실제 reconcile.sh 경로가 부모를 확정한다. 이는 문자열 검사만이 아니라 임시 fixture 안의 행동 검증이다.
- 실제 바이너리 계약 테스트는 `_bin`을 발견한 바이너리로 고정하여 임시 HOME에서 PATH에 의존하지 않도록 바뀌었고 실제 2.0.32로 통과했다.
**수정 전 대조군:** 현재 `test_issue60_opencode_v2.py`를 이전 HEAD `6b4b96f`의 별도 archive에 넣어 아래 다섯 노드만 실행했다. 모두 수정 전 동작 때문에 실패했다(**5 failed, 10 deselected in 2.57s**).
1. test_purge_takes_the_1x_rows_of_the_child_sessions_too
2. test_a_failed_delete_reports_the_child_sessions_left
3. test_the_background_service_alone_does_not_hold_a_session
4. test_the_data_dir_follows_the_callers_xdg_data_home_only_for_its_own_home
5. test_the_standalone_server_is_a_resident_child_but_its_tools_are_work
같은 다섯 노드는 현재 HEAD에서 통과했다. drift-C 필터 제거 변형은 이번 리뷰에서 별도로 반복하지 않았으며 현재 행동 테스트 통과를 확인했다.
## 원본 HEAD 실행 결과
다음 6개 모듈과 tier2 노드 1개 실행:
- tests/test_issue60_opencode_v2.py
- tests/test_a4_adapter_contract.py
- tests/test_issue31_purge_residual.py
- tests/test_issue42_skill_blocks.py
- tests/test_issue55_agent_homes.py
- tests/test_issue10_13_purge.py
- tests/test_tier2_component.py::test_comp_opencode_create_and_reconcile_and_stop
**1 failed, 186 passed, 1 skipped in 91.16s**, pytest exit 1. 유일한 실패는 위 XDG 변수 목록 검사다. skip은 untracked `.claude/skills`가 검증 worktree에 없는 issue42 검사이며, 실제 OpenCode 테스트는 skip되지 않았다.
변경 Python 파일의 시스템 Python 3.9 AST 구문 검사, 변경 셸 파일의 `/bin/bash -n`, base..head `git diff --check` 모두 통과. 전체 스위트는 오케스트레이터가 별도로 실행하는 범위라 반복하지 않았다.
## 안전 범위와 남은 제한
- 지정 venv, `PYTHONDONTWRITEBYTECODE=1`, `-p no:cacheprovider`, 검증 트리 밖 basetemp로 실행했다. HERDR/MAM/WORKSPACE/CLAUDE_CONFIG_DIR 바인딩은 제거했다.
- 실제 바이너리는 HOME/TMPDIR 임시화, XDG_* unset, 서버 사용 명령은 --standalone으로만 실행했다. `_run`이 설정하는 XDG_DATA_HOME도 해당 임시 홈 아래다. 모델 프롬프트나 실제 사용자 서비스/API 호출은 하지 않았다.
- XDG ambient 재현에는 임시 경로와 `_bin=None`만 사용했다. 실제 opencode 데이터나 실제 DB는 건드리지 않았다.
- 종료 후 OpenCode 프로세스는 기존 사용자 서비스 PID 86524만 남았다. 그 서비스는 제어하지 않았다. 시험 프로세스가 남지 않았다.
- 1차 리뷰에서 확인한 create→drift C→status→stop→resume 연결 및 실제 PTY의 `/exit` 성공 근거는 `.mam/jobs/614b6263/codex-reports/report-final.md`에 있다. 이번에는 새 tier2 행동 테스트와 실제 API 삭제 계약을 재검증했고, 실제 모델 첫 메시지는 보내지 않았다.
- status.sh의 `resume_on_disk`는 아직 기본 HOME 경로를 직접 찾는다. 사용자 지정 XDG 저장소에 대한 status 표시는 어댑터의 새 경로 지원과 다를 수 있다. 이는 1차 리뷰에서도 있던 비차단 후속 사항이며 이번 NOT PASS의 추가 사유로 삼지 않았다.
- Linux 실제 실행 결과는 없다. 구문 및 플랫폼 코드 경로를 검토했고 Linux 런타임/서비스 동작 검증은 예정된 후속 작업으로 남는다.
## 증거 파일
`/tmp/review-c4e96018-i02oz_61` (실경로 `/private/tmp/...`):
- head.log — 원본 결과 및 유일한 실패.
- before-fix.log — 이전 HEAD에서 새 회귀 검사 5건 실패.
- real_probe.py, real-probe/result.json — 실제 2.0.32 부모/자식 legacy 정리 확인.
- isolation.log — sandbox 밖 임시 XDG data root 선택 재현.
- isolation-control.log — 임시 격리 목록 보완 후 2건 통과.
- isolation-probe/tests/test_review_xdg_isolation.py — 읽기만 하는 재현 테스트. 이 복사본의 guard에는 대조군 변경이 적용돼 있으므로 원본 재현 시 HEAD의 guard를 사용해야 한다.
- processes-after.json — 시험 후 OpenCode 프로세스 확인.
[VERDICT: NOT PASS]
Loaded 100 of 363 files, more files were not shown because too many files have changed in this diff. Show more