Compare commits

..
32 Commits
Author SHA1 Message Date
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
81 changed files with 5005 additions and 541 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-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,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-20261026T133440Z-c8961b",
"base_sha": "29cc4fd90478936f15387a7665e2c95d13a3f14c",
"head_sha": "5f6065344f800c2c5ad6f802e95e9e8ff5cc7b26",
"tree_sha": "98d57bdc60920238c115ece90c90cc694f2744bc",
"review_commit": "5f6065344f800c2c5ad6f802e95e9e8ff5cc7b26",
"reviewer": "planner-reviewer-codex-01",
"job_id": "eb60f37d",
"verdict": "PASS",
"transport": "herdr",
"evidence": "review-tree",
"isolation": "worktree",
"verified_at": "2026-10-26T17:32:11Z"
}
@@ -0,0 +1,30 @@
# Review report — PR #73 / Issue #71
Reviewed validation snapshot `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2`: head `5f6065344f800c2c5ad6f802e95e9e8ff5cc7b26`, tree `98d57bdc60920238c115ece90c90cc694f2744bc`, base `29cc4fd90478936f15387a7665e2c95d13a3f14c`. The snapshot was clean; `git diff --check` reported no whitespace errors. No source files were changed.
## Findings
No blocking or non-blocking defects found in this pass.
The earlier rowless-stop finding about `HERDR_SESSION_NAME=default` was re-adjudicated in job `f40aa3bd` and withdrawn. That environment value is treated as unset by create: `create_session.sh:179-187,251-256` chooses the workspace server unless the caller supplies `--herdr-session`. Rowless stop follows the same resolution (`stop_session.sh:109-130`; `lib.sh:1681-1712`). The prior probe put an agent on `default` without the explicit option, a state that invocation of create would not produce. Deliberate default-server selection remains possible with `--herdr-session default` and the stop command accepts the same explicit option.
## Review checks
- `attach_command` is centralized in `lib_py/agents/sanitize.py` and used by create (`create_session.sh:512,566`), resume (`update_yaml_resumed.sh:119,151,161`), and reconcile (`reconcile.sh:569,676`). Coverage checks default server rendering, shell quoting, normalized names, and attach argument forwarding.
- Runtime references to removed `start_command` and `kill_command` fields are gone; only migration compatibility docs/code and assertions remain. The shim’s `new-session` and `kill-session` routes are still needed by the lifecycle scripts. Cleanup guidance now directs to the stop skill (`create_session.sh:364`, `lib_py/agents/id_order.py:342-345`).
- Rowless stop validates strict registry readability (`stop_session.sh:169-173`), refuses purge without a row (`124-126`), resolves cwd/root canonically before checking containment (`145-154`), and leaves registry bytes untouched (`156-164`). The safety and exit-code cases are covered by the new tests and the stop skill table (`SKILL.md:58-67`).
- The bash sanitizer is defined once (`lib.sh:44-82`), uses verified hash fallbacks, and errors when no conversion/hash tool is available. On macOS Bash 3.2, names containing `İ`, `-n`, and a trailing newline matched Python under `LC_ALL=C` and `LC_ALL=C.UTF-8`.
- Registry migration updates all named rows and format metadata (`registry_format.py:35-66`) before caller mutation under `BEGIN IMMEDIATE` (`atomic_yaml.py:78-88`); stale YAML is repaired from DB state (`127-138`). `atomic_dump_yaml` is the only sanctioned registry YAML writer. Future versions are not downgraded. The migration entry point’s output/error contract is documented and implemented in `registry_format.py:17-25,69-88`.
- StallWatch computes the normalized herdr name once and passes it to its source (`stall_watch.py:229`); its unchanged test passes.
- S11’s baseline `0fc162e` exists and is an ancestor of this head. Python 3.9.6 and Bash 3.2.57 are the host versions used. The scope excludes Issue #72’s update skill.
## Verification
Commands used the required `PYTHONDONTWRITEBYTECODE=1`, `-p no:cacheprovider`, and `--basetemp` outside the validation checkout.
- Head combined review run (all requested focused and adjacent suites): **461 passed, 1 skipped** in 1295.97s. This included `test_issue71_herdr_names.py`, `test_issue66_stall_watch_sanitized_name.py`, tier 1/2/3, Issues 23/24, 50, 21, 63, 60, layout, and skill blocks.
- Base Issue #71 regression run from the temporary base snapshot: **68 failed, 2 passed** in 121.85s, matching the expected base behavior.
- Unicode/name parity probe under C and C.UTF-8: outputs matched Python.
- `git diff --check`: clean.
[VERDICT: PASS]
@@ -0,0 +1,14 @@
{
"loop_id": "loop-20261026T133440Z-c8961b",
"base_sha": "29cc4fd90478936f15387a7665e2c95d13a3f14c",
"head_sha": "5f6065344f800c2c5ad6f802e95e9e8ff5cc7b26",
"tree_sha": "98d57bdc60920238c115ece90c90cc694f2744bc",
"review_commit": "5f6065344f800c2c5ad6f802e95e9e8ff5cc7b26",
"reviewer": "reviewer-agy-01",
"job_id": "0a1bb7e7",
"verdict": "PASS",
"transport": "herdr",
"evidence": "review-tree",
"isolation": "worktree",
"verified_at": "2026-10-26T17:32:11Z"
}
@@ -0,0 +1,128 @@
# PR #73 (`fix/issue-71`) Review Report — `agy` (`herdr:reviewer-agy-01`)
- **Job ID**: `0a1bb7e7`
- **Loop ID**: `loop-20261026T133440Z-c8961b` (iteration 2)
- **Issue / PR**: Issue #71 / PR #73 (`fix/issue-71`, 14 commits)
- **Base Commit**: `29cc4fd90478936f15387a7665e2c95d13a3f14c` (`main`, `v4.10.1`)
- **Head Commit**: `5f6065344f800c2c5ad6f802e95e9e8ff5cc7b26` (tree `98d57bdc60920238c115ece90c90cc694f2744bc`)
- **Review Worktree**: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2`
- **Host Environment**: macOS Darwin arm64 (`Darwin 25.3.0`), `/bin/bash 3.2.57(1)-release`, `/usr/bin/python3 3.9.6`
---
## 1. Executive Summary
PR #73 resolves Issue #71 across all 10 scope items and review refinements (`A`–`F`):
1. Unifies `attach_command` construction in [`lib_py/agents/sanitize.py::attach_command`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2/.agents/skills/lib_py/agents/sanitize.py#L34-L42) (`herdr --session <server> agent attach <sanitized_name>`) across `create_session.sh`, `update_yaml_resumed.sh` (both branches), and `reconcile.sh`, and teaches the `lib.sh` shim's `herdr agent attach` branch to resolve its target via `_resolve_herdr_target`.
2. Removes `kill_command` and `start_command` from newly written rows, `status.sh --json`, and `SKILL.md` examples, and replaces direct `herdr kill-session` cleanup hints in `create_session.sh` (exit 43) and `id_order.py` with `stop_session.sh --session <name> --herdr-session <srv>`.
3. Adds row-less session closing (`stop_rowless`) to [`stop_session.sh`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2/.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh#L123-L165) with strict registry readability gating (`load_state_json strict`), workspace boundary enforcement (`os.path.realpath` prefix-safe check, exit 5 on foreign workspace), server selection parity with `create_session.sh`, and registry immutability.
4. Consolidates `_sanitize_herdr_agent_name` into a single definition in [`lib.sh`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2/.agents/skills/lib.sh#L40-L85) injected into the generated shim via `declare -f`, achieving exact parity with Python's `sanitize_herdr_agent_name` across ASCII, non-ASCII/multibyte, option-like (`-n`, `-e`), and newline-containing names, while failing closed (nonzero, stderr) when no hash tool or `python3` is available instead of emitting `00000000` or truncated names.
5. Introduces registry format version 2 (`registry_format: 2`) in [`lib_py/registry_format.py`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2/.agents/skills/lib_py/registry_format.py#L1-L92) and [`lib_py/atomic_yaml.py`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2/.agents/skills/lib_py/atomic_yaml.py#L121-L138), migrating under the writer lock (`BEGIN IMMEDIATE`) and repairing stale YAML copies on retry.
6. Computes `herdr_name` once in `StallWatch.__init__` and passes it as a required keyword-only argument `name` to `HerdrSource`.
All 10 review checklist items, macOS `/bin/bash 3.2.57` and `/usr/bin/python3 3.9.6` compatibility checks, negative regression on `base` (`68 failed, 2 passed`), and 461 functional/integration tests on `head` (`461 passed, 1 skipped, 0 failed`) pass on macOS Darwin arm64.
---
## 2. Checklist Verification (Items 1–10)
### 1. Completion Criteria & Scope Discipline (Issue #71 & Scope Comment Items 1–10)
- **Verified (`PASS`)**:
- Every requirement in Issue #71 and the "범위 정리" comment (items 1–10) is implemented and covered by regression tests.
- Out-of-scope Issue #72 (`multi-agent-mux-update` skill) is not pulled into this PR; only the `python3 -m lib_py.registry_format --yaml <path>` entry point specified in item 9 is provided for #72 to call. `MAM_VERSION` (`4.10.1`) and `VERSIONS.md` are untouched as intended.
### 2. Regression Suite `tests/test_issue71_herdr_names.py` (Base vs Head & Reconcile Pane Precondition)
- **Verified (`PASS`)**:
- **Base (`29cc4fd90478936f15387a7665e2c95d13a3f14c`)**: Checked out into an isolated temporary directory outside the review worktree and ran `tests/test_issue71_herdr_names.py` (70 tests): **68 failed, 2 passed in 104.17s** (the only 2 passing tests on `base` are `test_the_names_need_converting_and_the_plain_one_does_not` and `test_wrapper_attach_calls_herdr_with_the_herdr_name[lab-plain-creator-claude]`, matching the PR description).
- **Head (`5f6065344f800c2c5ad6f802e95e9e8ff5cc7b26`)**: All **70 passed** (including all 4 `hashers` parameterizations: `shasum`, `sha1sum`, `openssl`, `python3`).
- **Reconcile test precondition**: [`test_reconcile_registers_with_the_attach_command`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2/tests/test_issue71_herdr_names.py#L128-L141) seeds `mock_herdr` with `agents={name: {"agent": "claude", "status": "running", "cwd": str(mam_sandbox), "workspace_id": "w1", "pid": 9999, "pane_id": "w1:p71", "command": "claude", "buffer": "Ready"}}` and asserts `len(rows) == 1` and `rows[0]["last_visible_note"] == "auto-registered by monitor"` before checking `_assert_new_form(rows[0], rows[0]["herdr_session"])`.
### 3. `attach_command` (`lib_py/agents/sanitize.py`) & `lib.sh` Wrapper `agent attach`
- **Verified (`PASS`)**:
- [`lib_py/agents/sanitize.py:34-42`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2/.agents/skills/lib_py/agents/sanitize.py#L34-L42) defines:
```python
def attach_command(server, name):
return "herdr --session %s agent attach %s" % (
shlex.quote(server or "default"), shlex.quote(sanitize_herdr_agent_name(name)))
```
- Always writes `--session default` when `server` is `"default"` or empty/falsy, and applies `shlex.quote` to both the server and sanitized agent name.
- Used by all three writers:
- `create`: [`create_session.sh:512, 566`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2/.agents/skills/multi-agent-mux-create/scripts/create_session.sh#L512-L566)
- `resume` (new row and existing row): [`update_yaml_resumed.sh:119, 150, 160`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2/.agents/skills/multi-agent-mux-resume/scripts/update_yaml_resumed.sh#L119-L160)
- `reconcile`: [`reconcile.sh:569, 676`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh#L569-L676)
- In [`lib.sh:605-613`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2/.agents/skills/lib.sh#L605-L613), the shim's `agent attach` branch shifts `<target>` (`t="$1"; shift`), resolves `tgt=$(_resolve_herdr_target "$t" || true)`, fails with exit 1 if empty, and invokes `_real_herdr agent attach "$tgt" "$@"`, preserving trailing flags such as `--takeover`.
### 4. Removal of `kill_command` & `start_command` and Updated Cleanup Hints
- **Verified (`PASS`)**:
- `git grep -E 'kill_command|start_command'` across the repository confirms no production code, skill doc, or active schema assertion reads or writes `kill_command` or `start_command` (the only remaining mentions are in `registry_format.py` to drop them during v1 -> v2 migration and in `test_issue71_herdr_names.py` / `test_tier2_component.py` asserting their absence).
- [`status.sh:221-231`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2/.agents/skills/multi-agent-mux-status/scripts/status.sh#L221-L231) and [`multi-agent-mux-status/SKILL.md:94-101`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2/.agents/skills/multi-agent-mux-status/SKILL.md#L94-L101) no longer include `start_command`, and nothing in the codebase reads `start_command` from `status.sh --json`.
- The shim's `new-session` and `kill-session` translation branches in `lib.sh` are properly retained because `create_session.sh`, `resume_session.sh`, `stop_session.sh`, and `reconcile.sh` call them internally.
- Both user-facing cleanup hints now point to `stop_session.sh`:
- [`create_session.sh:364`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2/.agents/skills/multi-agent-mux-create/scripts/create_session.sh#L364): `bash .agents/skills/multi-agent-mux-stop/scripts/stop_session.sh --session $SESSION_NAME --herdr-session ${HERDR_SESSION_NAME:-default}`
- [`lib_py/agents/id_order.py:359-360`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2/.agents/skills/lib_py/agents/id_order.py#L359-L360): `bash .agents/skills/multi-agent-mux-stop/scripts/stop_session.sh --session {m['session']} --herdr-session {srv}`
### 5. Row-less `stop` (`stop_session.sh`) & Strict Registry Read (`load_state_json strict`)
- **Verified (`PASS`)**:
- **Strict registry read gate**: [`stop_session.sh:170-193`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2/.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh#L170-L193) calls `STATE_JSON=$(load_state_json strict)` first. In [`lib.sh:1597-1631`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2/.agents/skills/lib.sh#L1597-L1631), `load_state_json strict` re-raises `sqlite3.OperationalError` on missing `state` or `sessions` tables as well as corrupt DB / malformed YAML, printing to `stderr` and exiting `1`. Furthermore, `MAPPED_DATA` exits `7` **only** when `d.get('herdr_sessions', [])` was validly iterated and had no matching dict row; non-dict rows (`herdr_sessions: [- 42]`) exit `1` (`cannot read the row`).
- **Workspace boundary check**: [`stop_rowless` (`stop_session.sh:145-155`)](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2/.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh#L145-L155) resolves both `PANE_CWD` and `WORKSPACE_ROOT` via `os.path.realpath` and checks `cwd == root or cwd.startswith(root + os.sep)`. Verified on macOS that this prevents prefix confusion (`/tmp/ws` vs `/tmp/ws-other`), handles symlinks (`/var` -> `/private/var`), and normalizes trailing slashes; out-of-workspace sessions are refused with exit code `5` before any `kill-session`.
- **Server selection order**: Matches `create_session.sh:184-188`: `--herdr-session` (`HERDR_SERVER_OPT`) -> ambient `HERDR_SESSION_NAME` (when non-empty and `!= "default"`) -> workspace server via `resolve_herdr_session`.
- **`--purge-conversation` and exit codes**: Refuses `--purge-conversation` on a row-less session with exit `1`, exits `6` if `herdr has-session` still succeeds after `kill-session`, never mutates `agent-sessions.yaml` or `agent-sessions.db`, and matches [`multi-agent-mux-stop/SKILL.md:59-67`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2/.agents/skills/multi-agent-mux-stop/SKILL.md#L59-L67).
### 6. Single Sanitize Rule in `lib.sh` & macOS BSD / Locale Parity
- **Verified (`PASS`)**:
- [`lib.sh:40-85`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2/.agents/skills/lib.sh#L40-L85) has the single definition of `_sanitize_herdr_agent_name`. [`_init_herdr_isolation` (`lib.sh:179-182`)](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2/.agents/skills/lib.sh#L179-L182) writes `declare -f _sanitize_herdr_agent_name` into the generated shim (`$wrapper_dir/herdr`).
- Removed the duplicate definition in the shim heredoc, removed `"00000000"` and `${s:0:32}` fallbacks, and removed `has-session`'s relative-path `sys.path.insert(0, os.path.abspath(".agents/skills"))` + `lambda s: s.lower()[:32]` fallback (`lib.sh:633-647` now computes `ssess=$(_sanitize_herdr_agent_name "$sess") || exit 1` in bash and passes `TARGET_SAN="$ssess"`).
- **macOS `/bin/bash 3.2.57`, BSD `tr`/`sed`, and locale (`LC_ALL=C` and `LC_ALL=en_US.UTF-8`) verification**:
- Pure-ASCII check uses `LC_ALL=C tr -d '\000-\177'`. For ASCII inputs, `printf '%s' "$n" | tr '\n' '-' | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9_-]/-/g'` avoids `echo` flag parsing (`-n`, `-e`, `-En`) and converts `\n` to `-` before `sed` or command substitution `$(...)` can strip trailing newlines.
- For non-ASCII inputs (`İ`, `é`, `ü`, `K`, CJK/Hangul, emoji), it delegates lowercasing and `re.sub(r"[^a-z0-9_-]", "-", ...)` to `python3` so character-by-character replacement and Unicode case folding (`\u0130`.lower() -> `'i\u0307'` -> `'i-'`) match `lib_py.agents.sanitize.sanitize_herdr_agent_name` byte-for-byte, and fails closed (exit 1, stderr) if `python3` is unavailable.
### 7. Registry Format Version 2 (`lib_py/registry_format.py`, `lib_py/atomic_yaml.py`)
- **Verified (`PASS`)**:
- Inside [`atomic_dump_yaml_main` (`lib_py/atomic_yaml.py:121-138`)](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2/.agents/skills/lib_py/atomic_yaml.py#L121-L138), after acquiring the flock and `BEGIN IMMEDIATE` and loading `state` + `sessions`, if `from_version < registry_format.CURRENT` (`2`), `registry_format.migrate(d)` runs before the caller's mutation and sets `force_yaml = True`.
- Migrates all rows in `herdr_sessions` (`running`, `stopped`, `terminated`, `archived`), drops `kill_command` and `start_command`, recomputes `attach_command`, and sets `d["registry_format"] = 2`.
- If the DB was already at format 2 (`elif row:`) but an earlier YAML `os.replace` failed after `conn.commit()`, `force_yaml = registry_format.stored_version(y) < registry_format.CURRENT` detects the stale YAML and rewrites it on the next write.
- **Future format preservation**: Verified that when `registry_format` > 2 (e.g., `3`), `stored_version(d)` returns `3`, `from_version < CURRENT` is `False`, and `force_yaml` is `False` — the writer neither downgrades `registry_format` nor clobbers future fields.
- **Other registry paths**: Checked all references to `agent-sessions.yaml` across `.agents/skills/`; `atomic_dump_yaml` is the sole writer in production (`create_session.sh`, `update_yaml_resumed.sh`, `stop_session.sh`, `reconcile.sh`, `orc_onboard.sh`), while `load_state_json` (`lib.sh` / `lib_py/state.py`) is strictly read-only.
- **CLI entry point**: `PYTHONPATH=.agents/skills python3 -m lib_py.registry_format --yaml <path>` prints `registry-format: <from> -> <to>, rows changed: <n>` and exits `0` (or nonzero with `registry-format: error: ...` on stderr when missing/failing), matching its module docstring contract.
### 8. `stall_watch.py` Single Sanitization Point
- **Verified (`PASS`)**:
- In [`.agents/skills/lib_py/stall_watch.py:158-163, 229-230`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2/.agents/skills/lib_py/stall_watch.py#L158-L230), `StallWatch.__init__` computes `self.herdr_name = sanitize_herdr_agent_name(args.target)` once and passes `name=self.herdr_name` (required keyword-only parameter) to `HerdrSource`.
- `tests/test_issue66_stall_watch_sanitized_name.py` is unmodified in `git diff` and all 7 of its tests pass.
### 9. Test Updates, `skill_code_blocks.json`, and `test_s11` Anchor (`0fc162e`)
- **Verified (`PASS`)**:
- `tests/test_issue50_pane_lock.py`, `tests/test_issue21_worktree_prereqs.py`, `tests/test_tier1_unit.py`, `tests/test_tier2_component.py`, and `tests/test_tier3_integration.py` preserve their original test intent after replacing assertions on removed `start_command`/`kill_command` fields.
- `tests/data/skill_code_blocks.json` and `tests/test_issue42_skill_blocks.py` pass all 96 skill-block verification tests.
- In [`tests/test_issue23_24_startup_path.py:306-335`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2/tests/test_issue23_24_startup_path.py#L306-L335), `test_s11_this_branch_does_not_change_the_generated_shim` pins `base = os.environ.get("MAM_BASE_SHA", "0fc162e")` and extracts both `_sanitize_herdr_agent_name() { ... }` and the `cat <<'EOF'` heredoc body in `_heredoc_body`. Commit `0fc162e` is an ancestor of `HEAD` (`5f60653`) on `fix/issue-71`; when merged into `main` via merge commit (as documented in PR #73), `0fc162e` remains reachable in git history so `test_s11` actively verifies the shim rather than skipping. (The `if show.returncode != 0: pytest.skip(...)` guard is pre-existing for shallow/archive checkouts without history.)
### 10. macOS Compatibility (`/usr/bin/python3 3.9.6`, `/bin/bash 3.2.57`, BSD Userland, SQLite WAL)
- **Verified (`PASS`)**:
- Parsed all 16 modified/added Python files with `/usr/bin/python3` (`3.9.6`) `ast.parse(..., feature_version=(3, 9))` and imported `lib_py.agents.sanitize`, `lib_py.registry_format`, `lib_py.atomic_yaml`, and `lib_py.stall_watch` under system Python 3.9.6.
- Checked all 7 modified bash scripts and the generated `.mam/shim/herdr` with `/bin/bash -n` (`GNU bash 3.2.57(1)-release`); no bash 4+ constructs (`declare -A`, `${var,,}`, `mapfile`, `readarray`) are used.
- In `tests/test_issue71_herdr_names.py:514-527`, `test_stop_does_not_take_an_unreadable_registry_for_no_row` unlinks `agent-sessions.db*` / `agent-sessions.db-*` before writing corrupt DB/YAML fixtures, properly accounting for macOS SQLite's persistent `-wal`/`-shm` sidecars.
---
## 3. Test Execution Summary on macOS
| Suite / Command | Target | Result |
|---|---|---|
| `git diff --check 29cc4fd..5f60653` | Review worktree `/2` | Clean (exit 0) |
| `/bin/bash -n` on all 7 modified `.sh` scripts | `/bin/bash 3.2.57` | All 7 OK (exit 0) |
| `/usr/bin/python3` (`3.9.6`) AST + import + `LC_ALL=C`/`en_US.UTF-8` sanitize & path checks | System Python 3.9.6 + `/bin/bash 3.2.57` | `ALL_LOCALE_AND_MULTIBYTE_PARITY_OK`, `FUTURE_FORMAT_PRESERVED_OK`, `STOP_ROWLESS_PATH_BOUNDARY_CHECKS_OK` (exit 0) |
| `pytest tests/test_issue71_herdr_names.py` | **Base** (`29cc4fd` in temp dir) | **68 failed, 2 passed** in 104.17s |
| `pytest tests/test_issue71_herdr_names.py tests/test_issue66_stall_watch_sanitized_name.py tests/test_layout.py tests/test_issue42_skill_blocks.py` | **Head** (`5f60653`, worktree `/2`) | **152 passed, 1 skipped** in 150.63s (all 70 in `test_issue71` and all 7 in `test_issue66` passed) |
| `pytest tests/test_issue60_linux_fixes.py tests/test_tier1_unit.py tests/test_tier2_component.py tests/test_tier3_integration.py tests/test_issue23_24_startup_path.py tests/test_issue50_pane_lock.py tests/test_issue21_worktree_prereqs.py tests/test_issue63_same_cwd_ids.py` | **Head** (`5f60653`) | **309 passed, 0 failed** |
---
## 4. Defects & Observations
### Blocking Defects
- None.
### Non-Blocking Observations
- In [`.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job:1021`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job#L1021), the informational message after `send-keys` prints `(연결하려면: HERDR_SESSION_NAME=$HERDR_SESSION_NAME herdr agent attach $sess — lib.sh를 source한 셸에서 실행)`. Because PR #73 updated the `lib.sh` shim's `herdr agent attach` handler to resolve `$sess` via `_resolve_herdr_target`, this command works as stated when `lib.sh` is sourced, though a future cleanup could also print the native `herdr --session ... agent attach ...` form.
[VERDICT: PASS]
@@ -0,0 +1,14 @@
{
"loop_id": "loop-20261025T023502Z-d6f876",
"base_sha": "0461a632eb667c7988fd33be92e9a4b36151f22a",
"head_sha": "bb496fde785133ad1dbec288aa7431766efb6eb5",
"tree_sha": "15a72c9a1933c64018a2f2d9aa9707348905bb4d",
"review_commit": "bb496fde785133ad1dbec288aa7431766efb6eb5",
"reviewer": "reviewer-agy-01",
"job_id": "927ed539",
"verdict": "PASS",
"transport": "herdr",
"evidence": "review-tree",
"isolation": "worktree",
"verified_at": "2026-10-25T03:40:44Z"
}
@@ -0,0 +1,79 @@
# PR #70 (`fix/issue-66`) Review Report — `agy` (`herdr:reviewer-agy-01`)
- **Job ID**: `927ed539`
- **Issue / PR**: Issue #66 / PR #70 (`fix/issue-66`)
- **Base Commit**: `0461a632eb667c7988fd33be92e9a4b36151f22a`
- **Head Commit**: `bb496fde785133ad1dbec288aa7431766efb6eb5` (tree `15a72c9a1933c64018a2f2d9aa9707348905bb4d`)
- **Review Worktree**: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261025T023502Z-d6f876/1`
- **Host Environment**: macOS Darwin arm64 (`Darwin 25.3.0`), `/bin/bash 3.2.57(1)-release`, `/usr/bin/python3 3.9.6`
---
## 1. Executive Summary
PR #70 fixes Issue #66, where `lib_py.stall_watch` passed the unsanitized `--target` session name to `herdr agent get|read|prompt|wait`, causing `stall_watch` to immediately fail with `stall_watch:agent_not_found` (exit code 2) whenever the session name had been transformed by `_sanitize_herdr_agent_name` / `sanitize_herdr_agent_name` at session creation time (e.g. names > 32 characters such as worktree-scoped loop agents, uppercase characters, punctuation, or non-letter leading characters).
All 7 review checklist items, static/Python 3.9 compatibility checks, negative regression checks on `base` (`0461a632eb667c7988fd33be92e9a4b36151f22a`), and functional/regression test runs on `head` (`bb496fde785133ad1dbec288aa7431766efb6eb5`) pass cleanly with zero blocking or non-blocking defects.
---
## 2. Detailed Checklist Verification
### 1. Does `stall_watch.py` use `sanitize_herdr_agent_name` for every `herdr` call (`agent get`, `agent read`, `agent prompt`, `agent wait`)?
- **Verified (`PASS`)**:
- In [`.agents/skills/lib_py/stall_watch.py`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261025T023502Z-d6f876/1/.agents/skills/lib_py/stall_watch.py#L158-L212), `HerdrSource.__init__` computes `self.name = sanitize_herdr_agent_name(target)` while preserving `self.target = target`:
- `HerdrSource.agent_get` (line 183): `self._herdr("agent", "get", self.name)`
- `HerdrSource.input_empty` (line 206): `self._herdr("agent", "read", self.name)`
- `HerdrSource.prompt` (line 211): `self._herdr("agent", "prompt", self.name, text)`
- (`HerdrSource.agent_pid` at line 187 and `HerdrSource.blocked_text` at line 193 take `pane_id` (`wN:pM`) returned by `agent get`, not the agent name; `_herdr` at line 168 passes `self.session` to `--session`, which is the herdr server session `--herdr-session`.)
- In [`StallWatch.__init__`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261025T023502Z-d6f876/1/.agents/skills/lib_py/stall_watch.py#L225-L234) and [`StallWatch.run`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261025T023502Z-d6f876/1/.agents/skills/lib_py/stall_watch.py#L331), `self.herdr_name = sanitize_herdr_agent_name(args.target)` is passed to `watcher.wait_once(self.herdr_name, until=until, timeout_ms=tmo, session=self.args.herdr_session)`.
- Confirmed that `lib_py.agents.sanitize.sanitize_herdr_agent_name` and `lib.sh::_sanitize_herdr_agent_name` produce identical outputs across empty strings, lowercase/uppercase names, leading digits/dashes, >32-char names (`{s[:23]}-{sha1(s)[:8]}`), and special characters.
### 2. Are there any other places where `stall_watch.py` calls `herdr` with an unsanitized agent name?
- **Verified (`PASS`)**:
- Inspected all occurrences of `_herdr`, `herdr`, `wait_once`, `self.target`, and `self.args.target` across [`.agents/skills/lib_py/stall_watch.py`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261025T023502Z-d6f876/1/.agents/skills/lib_py/stall_watch.py) and [`.agents/skills/lib_py/loop/watcher.py`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261025T023502Z-d6f876/1/.agents/skills/lib_py/loop/watcher.py). No `herdr` invocation uses an unsanitized agent name.
### 3. Does `stall_watch.py` still keep the original `target` name for human-facing log lines and notifications?
- **Verified (`PASS`)**:
- All log lines in `StallWatch` (`stall_watch.py:373`, `397`, `405-406`) continue to format `self.args.target` (the original unsanitized session name):
- `[stall-watch] idle detected for {self.args.target} (job {self.args.job_id}); sending nudge`
- `[stall-watch] blocked detected for {self.args.target} (job {self.args.job_id}): {text}`
- `[stall-watch] cannot judge input box for {self.args.target} (agent={self.kind}, job {self.args.job_id}); nudge withheld`
- Progress notifications (`notify_progress`) continue to publish `stalled:idle`, `stalled:blocked ...`, `stalled:unverified_input ...`, and `stall_watch:...` keyed by `--job` (`self.args.job_id`).
### 4. Does `resolve_agent(agent, target)` still receive the original `target` name?
- **Verified (`PASS`)**:
- Both `HerdrSource.__init__` (`stall_watch.py:163`) and `StallWatch.__init__` (`stall_watch.py:230`) pass the original `target` (`args.target`) to `resolve_agent(...)`.
- When `--agent` is omitted, `resolve_agent` infers the agent kind from the `-<agent>` suffix of `target` (e.g., `mam-wt-issue-66-very-long-creator-codex` -> `codex`). Passing the sanitized name (`mam-wt-issue-66-very-lo-823a0a24`) would have lost the `-codex` suffix due to 32-character truncation + SHA-1 suffixing. Keeping `resolve_agent` on the original `target` is verified by `test_the_kind_of_a_long_name_comes_from_its_original_suffix`.
### 5. In `tests/conftest.py`, is the `strict_agent_names` opt-in clean and backward-compatible with existing tests?
- **Verified (`PASS`)**:
- In [`tests/conftest.py`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261025T023502Z-d6f876/1/tests/conftest.py#L473-L476), `_match_agent(k, target)` checks `if state.get("strict_agent_names"): return k == target` and otherwise falls back to the existing substring match (`k == target or target in k or k in target`).
- When `strict_agent_names` is enabled and an agent is not found, `cmd2 == "get"` (`tests/conftest.py:1058-1061`) and `cmd2 == "read"` (`tests/conftest.py:1153-1156`) emit `{"error": {"code": "agent_not_found", "message": "agent " + name + " not found"}}` on `stderr` with exit code 1, matching `wait`, `prompt`, and real `herdr`.
- Existing tests that do not set `strict_agent_names` retain identical behavior.
### 6. Are the new tests in `tests/test_issue66_stall_watch_sanitized_name.py` genuine behavioral tests that would fail without the fix in `stall_watch.py`?
- **Verified (`PASS`)**:
- Executed `tests/test_issue66_stall_watch_sanitized_name.py` + updated `tests/conftest.py` against `base` (`0461a632eb667c7988fd33be92e9a4b36151f22a`, without the `stall_watch.py` fix):
- **5 failed, 2 passed**: Every test case where `sanitize_herdr_agent_name(target) != target` (`creator-claude-fix-issue-99-long-branch`, `Creator-Claude-T`, and `mam-wt-issue-66-very-long-creator-codex`) failed on `base` (`stall_watch:agent_not_found` instead of blocked notice, or empty prompt list instead of idle nudge), while the 2 control tests (`test_the_names_change_and_the_strict_mock_answers_only_the_sanitized_one` and `test_an_idle_agent_is_nudged_through_the_sanitized_name[creator-claude-t]`) passed.
- On `head` (`bb496fde785133ad1dbec288aa7431766efb6eb5`), all **7 passed**.
### 7. Is the one-off failure in `test_issue17_item4_decisions.py::test_c_finalize_ends_with_an_unreaped_wrapper[broker_unavailable-3-0]` unrelated to this diff?
- **Verified (`PASS`)**:
- Inspected `tests/test_issue17_item4_decisions.py:204-221` and `565-601`: that test stubs `*lib_py.stall_watch*` in its `$PY` wrapper to `exit 0` immediately and tests `finalize_unfinished` re-observing a live `lib_py.delegation.wrapper` process across a 3-second `broker_unavailable` window (`touch ready` + `time.sleep(2)` in `_terminate_unreaped`). It never imports or executes `lib_py.stall_watch` or `strict_agent_names`.
- Both `test_c_finalize_ends_with_an_unreaped_wrapper[broker_unavailable-3-0]` and `[timeout-2-10]` passed on `head` during our verification run.
---
## 3. Verification Summary
| Check | Command / Scope | Result |
|---|---|---|
| Whitespace / patch hygiene | `git diff --check 0461a632eb667c7988fd33be92e9a4b36151f22a..bb496fde785133ad1dbec288aa7431766efb6eb5` | Clean (exit 0) |
| macOS system Python 3.9 syntax & import | `/usr/bin/python3` (`3.9.6`) `ast.parse(..., feature_version=(3, 9))` + `import lib_py.stall_watch` | `PY39_AND_SANITIZE_EQUIV_OK` (exit 0) |
| Bash vs Python sanitizer equivalence | `lib.sh::_sanitize_herdr_agent_name` vs `sanitize_herdr_agent_name` across 8 edge cases | Exact match (exit 0) |
| Negative regression on `base` (`0461a63`) | `pytest tests/test_issue66_stall_watch_sanitized_name.py` on `0461a63` + strict mock | `5 failed, 2 passed` (reproduces `stall_watch:agent_not_found`) |
| Behavioral & regression suites on `head` (`bb496fd`) | `pytest tests/test_issue66_stall_watch_sanitized_name.py tests/test_issue30_stall_watch.py tests/test_issue30_stall_watch_stop.py tests/test_issue17_item4_decisions.py -k "..."` | `91 passed, 1 skipped` in 399.91s (exit 0) |
| Worktree cleanliness | `git status --short` in review worktree | Clean (no untracked or modified files) |
[VERDICT: PASS]
@@ -0,0 +1,14 @@
{
"loop_id": "loop-20261025T023502Z-d6f876",
"base_sha": "0461a632eb667c7988fd33be92e9a4b36151f22a",
"head_sha": "bb496fde785133ad1dbec288aa7431766efb6eb5",
"tree_sha": "15a72c9a1933c64018a2f2d9aa9707348905bb4d",
"review_commit": "bb496fde785133ad1dbec288aa7431766efb6eb5",
"reviewer": "reviewer-grok-01",
"job_id": "7b602181",
"verdict": "PASS",
"transport": "herdr",
"evidence": "review-tree",
"isolation": "worktree",
"verified_at": "2026-10-25T03:40:44Z"
}
@@ -0,0 +1,86 @@
# Review 7b602181 — PR #70 / Issue #66, Mac pass
PASS. Head `bb496fd` calls herdr with `sanitize_herdr_agent_name` of the session. `agent get`, `agent read`, `agent prompt`, and `agent wait` all use that name. The agent kind is still taken from `--agent` or from the original session suffix. Log lines keep the original session name. A name that is already legal is left as it is.
Reviewed only the snapshot `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261025T023502Z-d6f876/1` (`loop-20261025T023502Z-d6f876`). Head `bb496fde785133ad1dbec288aa7431766efb6eb5`, tree `15a72c9a1933c64018a2f2d9aa9707348905bb4d`. Base `0461a632eb667c7988fd33be92e9a4b36151f22a` (main, v4.10.0) is an ancestor and is the control-plane HEAD. One commit, 3 files, +150/−5. No uncommitted changes. `git diff --check` is clean. Snapshot porcelain is empty and the snapshot has no `.mam`. Control-plane porcelain is empty. No product file was edited. The source worktree `mam-wt-pr70` was not run.
The commit is `bb496fd` `fix(stall_watch): call herdr with the sanitized agent name`.
Host tools: control-plane venv Python 3.9.6, `/bin/bash` 3.2.57(1)-release. The real agent databases were not opened.
## Herdr name and kind
`HerdrSource` keeps the original string on `self.target` and stores `self.name = sanitize_herdr_agent_name(target)` (`stall_watch.py:158-165`). `agent_get`, `agent read`, and `agent prompt` pass `self.name` (`182-184`, `206`, `210-211`). `StallWatch.herdr_name` is the same function of `args.target` (`229`). `watcher.wait_once` receives that name, and `args.herdr_session` stays the server (`331`).
`build_argv` puts a non-default server on `herdr --session` and the target on `agent wait <target>` (`loop/watcher.py:131-139`). `HerdrSource._herdr` does the same split (`stall_watch.py:167-168`). The server argument is not run through the agent-name sanitizer.
`resolve_agent` still reads `--agent`, then its first hyphen piece, then `agent_of_row` on the original session name (`stall_watch.py:140-146`, `registry.py:30-49`, `base.py:326-330`). Measured on this snapshot: `resolve_agent("", "mam-wt-issue-66-very-long-creator-codex")` is `codex`. The sanitized form `mam-wt-issue-66-very-lo-d55dfa83` does not end in `-codex`, and `resolve_agent` of that string with an empty `--agent` is `None`. `resolve_agent("claude", <39-character name>)` is `claude`. `resolve_agent("claude-code", ...)` is `claude`. The long-name test passes `--agent ""` and still nudges, which is the codex input-box path (`test_issue66_stall_watch_sanitized_name.py:111-127`).
No remaining herdr agent-name argument in `stall_watch.py` uses `self.target` or `args.target`. Those strings appear in the log lines (`373`, `397`, `406`). Pane calls use the pane id (`186-193`).
`_notify` publishes `progress` with the detail it is given (`237-244`). Idle publishes `stalled:idle` (`408`). Blocked publishes `stalled:blocked` plus the dialog line (`374`). Those details contain the stall reason. The original session name is in the matching log line (`agent <original> idle for`, `agent <original> is blocked`). The tests lock both (`79-85`, `101-106`).
## Sanitizer
`sanitize_herdr_agent_name` (`lib_py/agents/sanitize.py:4-30`) lowercases, replaces characters outside `[a-z0-9_-]` with `-`, prefixes `x-` when the result does not start with a letter, and when the result is longer than 32 characters keeps 23 characters, a hyphen, and 8 hex digits of SHA-1.
The function `lib.sh` runs is the definition at `lib.sh:293-316` (an earlier copy at `36-61` is replaced when the file is sourced; every call site is below 316). It was executed on its own, without sourcing `lib.sh`. On this Mac, with `shasum` present, it returned the same strings as the Python function:
| input | result |
| --- | --- |
| `creator-claude-fix-issue-99-long-branch` (39) | `creator-claude-fix-issu-e6fc18ae` |
| `Creator-Claude-T` | `creator-claude-t` |
| `creator-claude-t` | `creator-claude-t` |
| `mam-wt-issue-66-very-long-creator-codex` | `mam-wt-issue-66-very-lo-d55dfa83` |
| empty | `agent` |
| `1abc` | `x-1abc` |
| `A B` | `a-b` |
| 33 `x` | `xxxxxxxxxxxxxxxxxxxxxxx-60bbb3c8` |
| `planner-agy` | `planner-agy` |
The earlier shell copy produced the same three long, codex, and `1abc` results. Python 3.9.6 imports `stall_watch`. The new code adds no `match` statement and no evaluated `X | Y` annotation. The module already has `from __future__ import annotations`.
## Mock
`strict_agent_names` is off unless a test sets it. `_match_agent` then keeps the previous comparison (`conftest.py:470-476`). With the flag set, the mock answers only an exact registered name. `agent get` and `agent read` then print `agent_not_found` JSON (`1057-1062`, `1152-1158`). `agent wait` and `agent prompt` already did (`1089-1096`, `1187-1194`). Existing tests that leave the flag unset keep the old not-found text on get and read.
## Tests
`test_the_names_change_and_the_strict_mock_answers_only_the_sanitized_one` registers the sanitized name and checks that get, read, wait, and prompt reject the original name with `agent_not_found` and accept the sanitized name (`51-65`).
`test_an_idle_agent_is_nudged_through_the_sanitized_name` covers the 39-character name, `Creator-Claude-T`, and `creator-claude-t`. Every get, read, prompt, and wait uses the sanitized name, one nudge is sent, the progress detail is `stalled:idle`, and the log contains `agent <original> idle for` (`68-85`).
`test_a_blocked_agent_is_reported_with_the_original_name` uses the sanitized name on the herdr calls, sends no prompt, publishes `stalled:blocked` plus the dialog line, and logs `agent <original> is blocked` (`90-106`).
`test_the_kind_of_a_long_name_comes_from_its_original_suffix` passes an empty `--agent`. The sanitized name does not end in `-codex`. The watcher still nudges and every herdr call uses the sanitized name (`111-127`).
## Results
Pytest used the control-plane venv, `PYTHONDONTWRITEBYTECODE=1`, `-p no:cacheprovider`, and `--basetemp` `/tmp/mam7b6-7b602181-pytest` (head) and `/tmp/mam7b6-7b602181-base-pytest` (base). Neither path contains `mam-review` or `shim`. Inherited `HERDR_*`, `MAM_*`, `MQTT_*`, `SKS_*`, `DELEGATE_JOB_LOGS_DIR`, `HOME_DIR`, `CLAUDE_*`, `CODEX_HOME`, `PYTHONPATH`, `WORKSPACE_ROOT`, `MAM_ENV_FILE`, `MAM_JOBS_DIR`, `AGENT_SESSIONS_YAML`, the four `XDG_*` homes, and `AGENT_PYTHON_BIN` were dropped.
Head, cwd the snapshot: `tests/test_issue66_stall_watch_sanitized_name.py`, `test_issue30_stall_watch.py`, `test_issue30_stall_watch_stop.py`. **66 passed, 1 skipped** in 356.72 s. Exit 0. The log closed at 2026-10-25 12:31:50. The skip is `test_s6_the_heartbeat_timer_is_not_a_background_child`, marked for Linux because it reads `/proc` (`test_issue30_stall_watch.py:39-40`, `340-341`). That test is outside this delta. The seven Issue #66 tests passed inside the 66.
Base, this turn: a throwaway `git clone --shared` checked out at `0461a632eb667c7988fd33be92e9a4b36151f22a`, with only `tests/test_issue66_stall_watch_sanitized_name.py` copied from head. Base `stall_watch.py` has no `sanitize_herdr` use. **6 failed, 1 passed** in 37.05 s. Exit 1. The pass is `test_an_idle_agent_is_nudged_through_the_sanitized_name[creator-claude-t]`, the name that needs no conversion. The six failures are the long name, the uppercase name, and the long codex name. On that tree get, read, and wait are invoked with `creator-claude-fix-issue-99-long-branch` and `Creator-Claude-T`. The snapshot was not checked out. Snapshot porcelain stayed empty.
## Issue #17 timing failure
`tests/test_issue17_item4_decisions.py::test_c_finalize_ends_with_an_unreaped_wrapper[broker_unavailable-3-0]` is outside this diff. The test sends `SIGTERM` to a delegate wrapper during finalize and expects exit `-15` within 4.5 seconds, an empty call log, and an unchanged job record (`583-601`). The diff names only `stall_watch.py`, `tests/conftest.py`, and the new Issue #66 test. It does not touch the wrapper, `finalize_unfinished`, or that test. A miss on that timing assertion is a cleanup race in the wrapper test, separate from the herdr agent name.
## Blocking defects
None.
## Non-blocking
- `lib.sh` contains two copies of `_sanitize_herdr_agent_name` (`36-61` and `293-316`). Sourcing the file uses the second. On this host, with `shasum` present, both copies matched Python for the names checked above. The first copy's missing-hasher path truncates to 32 characters with no hash. This commit does not change `lib.sh`.
- Progress details stay `stalled:idle` and `stalled:blocked <dialog>`. They carry the stall reason. The original session name is on the log line beside that notice.
## Host check
No process has a first command token of `opencode` or a path ending in `/opencode`. `lsof` on `~/.local/share/opencode/opencode.db` exited 1. That database was not opened. Its mtime is 1792641027 and its size is 36712448. `opencode.log` is absent. `~/.config/opencode/service.json` mtime is 1792246838 and its size is 64. `~/.local/state/opencode/service.json` is absent. The herdr config mtime is 1784456394. `~/.local/state/mam` mtime is 1792846843. `~/.local/state/mam/id-order` is a directory, mtime 1792846886. These stats match the check taken before the suite.
Five herdr processes were running immediately before the suite. After the suite log closed at 2026-10-25 12:31:50, the 12:31:59 check still found five. One pid from the before-set was gone. The replacement is `/opt/homebrew/bin/herdr` with `--session multi-agent-mux`, elapsed time about 15 seconds at 12:31:59, so it started near 12:31:44, during the last seconds of the suite. No herdr command line contained the review basetemp, the snapshot path, or `d6f876`. This review did not start or stop a herdr server.
After this report file was written, the same check was repeated at 2026-10-25 12:33:25. Snapshot porcelain is empty and the snapshot has no `.mam`. Control-plane porcelain is empty at `0461a632eb667c7988fd33be92e9a4b36151f22a`. No process has a first command token of `opencode` or a path ending in `/opencode`, and `lsof` still reports no database holder. The database mtime is still 1792641027 and its size is still 36712448. `opencode.log` is still absent. The config service file mtime is still 1792246838. The state service file is still absent. The herdr config mtime is still 1784456394. `~/.local/state/mam` mtime is still 1792846843, and `id-order` is still the same directory (mtime 1792846886). Five herdr processes were present. The pid set differs from the 12:31:59 set by one homebrew herdr process whose arguments include `--session multi-agent-mux`; its elapsed time was about 41 seconds, so that process had turned over again. No herdr command line contained the review basetemp, the snapshot path, or `d6f876`. This review did not start or stop it.
[VERDICT: PASS]
@@ -0,0 +1,14 @@
{
"loop_id": "loop-20261026T133440Z-c8961b",
"base_sha": "29cc4fd90478936f15387a7665e2c95d13a3f14c",
"head_sha": "5f6065344f800c2c5ad6f802e95e9e8ff5cc7b26",
"tree_sha": "98d57bdc60920238c115ece90c90cc694f2744bc",
"review_commit": "5f6065344f800c2c5ad6f802e95e9e8ff5cc7b26",
"reviewer": "reviewer-grok-01",
"job_id": "fd90992c",
"verdict": "PASS",
"transport": "herdr",
"evidence": "review-tree",
"isolation": "worktree",
"verified_at": "2026-10-26T17:32:11Z"
}
@@ -0,0 +1,102 @@
# Review: PR #73 / Issue #71 (macOS, snapshot 2)
Job fd90992c. Reviewer only. Checks ran on the review snapshot. The source worktree and the control-plane checkout were not edited.
## Identity
- Snapshot: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2`
- Loop: `loop-20261026T133440Z-c8961b`
- Base: `29cc4fd90478936f15387a7665e2c95d13a3f14c` (main, v4.10.1)
- Head: `5f6065344f800c2c5ad6f802e95e9e8ff5cc7b26`
- Tree: `98d57bdc60920238c115ece90c90cc694f2744bc`
- 14 commits, base is an ancestor, porcelain 0, no `.mam` in the snapshot, `git diff --check` clean
- Diff: 28 files, +953/−176
- Host: `/usr/bin/python3` 3.9.6, bash 3.2.57(1)-release, sqlite 3.51.0, `LANG=en_US.UTF-8`
`tests/test_issue66_stall_watch_sanitized_name.py` has an empty diff against `29cc4fd`. Commit `0fc162e` is an ancestor. The diff file list has no `multi-agent-mux-update` skill. `MAM_VERSION` stays `4.10.1`.
Issue #71, the "범위 정리" comment (items 1–10), and PR #73 were read. The registry entry point is documented for #72 and is not wired to an update skill.
## What was checked
### attach_command
`lib_py/agents/sanitize.py:34` builds `herdr --session <server> agent attach <sanitized name>`. An empty server becomes `default`, and both the server and the name go through `shlex.quote`, so `default` is written as `--session default` and a server that needs quotes is quoted.
The three writers call it:
- create, `create_session.sh:566`
- resume, `update_yaml_resumed.sh:151` (new row) and `:161` (existing row whose server is set)
- reconcile, `reconcile.sh:676`, on the row whose note is `auto-registered by monitor`
The shim `agent attach` (`lib.sh:605-613`) resolves the target with `_resolve_herdr_target` and runs `_real_herdr agent attach "$tgt" "$@"`. Options after the name stay in `"$@"`.
### Removed fields and the stop hints
`kill_command` and `start_command` remain only as fields the migration pops and as assertions that they are absent. `status.sh` no longer emits `start_command`. The shim still has the fake `new-session` and `kill-session` verbs, which create, stop, and reconcile use. `kill-session` sanitizes the name and exits 1 when that fails.
Create exit 43 (`create_session.sh:364`) and `id_order.py:330` and `:344` tell the user to run `stop_session.sh --session <name> --herdr-session <server>`. The row-less tests below run that script.
### Row-less stop
`stop_rowless` (`stop_session.sh:123`) runs only when the row lookup exits 7 (`:189`). A strict read failure exits 1 before that (`:170`). `--purge-conversation` with no row exits 1 (`:124`). A cwd outside this workspace exits 5. A session that survives `kill-session` exits 6. Those codes match `multi-agent-mux-stop/SKILL.md:58-66`.
Server order: `--herdr-session`, else a non-default `HERDR_SESSION_NAME` captured before resolve (`:109`, `:128`), else the workspace server. `lib.sh` stamps an unset name as `default`, and the row-less path treats that stamp as unset.
The cwd check realpaths both sides and accepts the workspace itself or a child (`root + os.sep`). That rejects a sibling prefix and a symlink whose target is outside the workspace, and it treats a trailing slash as the same directory. `/tmp` and `/private/tmp` are the same path on this Mac. The row-less tests compare registry bytes before and after and passed.
`load_state_json strict` (`lib.sh:1594`) is the bash reader. A missing `state` or `sessions` table raises in strict mode and the function exits 1. The argument-free call still prints `{}`.
### One name rule
`lib.sh` has one `_sanitize_herdr_agent_name` (`:44`). `00000000` and `lower()[:32]` are absent. The shim receives the function through `declare -f` (`:181`).
The function was extracted with `sed` and run under `/bin/bash` 3.2 without sourcing `lib.sh`. Against `sanitize_herdr_agent_name` there were 0 mismatches under `LC_ALL=C` and `LC_ALL=en_US.UTF-8` for empty, `Upper-Case`, `-n`, `-e`, a 33-character name, the long branch name, `İstanbul-creator-claude`, a newline, `a\nb`, café, the kelvin sign, Japanese, Korean `한글`, and `A B`. Korean is two characters; the non-ASCII path uses `python3` `str.lower()` and the same regex, so the replacement follows characters, and the result matched Python.
With `PATH` limited to `tr`, `sed`, `awk`, and `cut`, the long name exits 1 with `no shasum, sha1sum, openssl or python3 to hash it` and prints no name. `한글` exits 1 with `not ASCII`.
`/usr/bin/python3` is 3.9.6. The diff adds no `mapfile`, `${var,,}`, `declare -A`, `match`, or `tomllib`. A throwaway SQLite database opened with `PRAGMA journal_mode=WAL`, written, and closed still had a `-wal` (0 bytes) and a `-shm` (32768 bytes).
### Registry format 2
`atomic_yaml.py:118-137` migrates under the lock, after `BEGIN IMMEDIATE` and before the caller mutation, when `stored_version < 2`. Migration sets `force_yaml`, so YAML is rewritten even if the mutation changes no session. If the DB is already current and the YAML copy is still older, the next write rewrites the YAML. `migrate` (`registry_format.py:50-66`) updates every named row, including stopped and terminated, and sets `registry_format` to 2. A version already greater than 2 is not less than `CURRENT`, so the writer does not call `migrate`.
The entry point `python -m lib_py.registry_format --yaml <path>` prints `registry-format: <from> -> <to>, rows changed: <n>` and exits 0, or `registry-format: error:` and a nonzero status when neither file exists. The only `yaml.safe_dump` under `.agents/skills` is the writer. Readers (`lib_py/state.py` and the argument-free bash loader) do not migrate.
### stall_watch and the edited tests
`stall_watch.py:229` computes `self.herdr_name` once and passes it into `HerdrSource`. The issue #66 test file is unchanged and is in the passing run.
Edited assertions keep the old intent: pane-lock uses `session_id_source` (create sets it at `create_session.sh:579`) to tell a create row from a monitor row; the worktree test looks for a leaked `WORKSPACE_ROOT=` in `attach_command`; tier 2 checks the new attach string and the absence of the two dropped keys; tier 1 and tier 3 plant a row with no agent so "cannot infer agent" still exits 2. `skill_code_blocks.json` moved with the docs, and `test_issue42_skill_blocks.py` expects stop exits `0` through `6`. That file passed aside from the skills-link skip.
S11 (`test_issue23_24_startup_path.py:306-310`) compares the shim with `0fc162e`. That commit is in this checkout, so the comparison ran as part of the passing file. If the object is missing, the test calls `pytest.skip` with a message that names the commit. That skip is intentional: the PR says a squash would drop `0fc162e`, and it asks for a merge commit.
The reconcile tests plant a mock pane (`test_issue71_herdr_names.py:127-140`) and require one new row. They passed, so registration ran.
## Tests
Pytest was `PYTHONDONTWRITEBYTECODE=1` with the control-plane venv, `-p no:cacheprovider`, and `--basetemp=/tmp/mamfd9-fd90992c-pytest`. Inherited `HERDR_*`, `MAM_*`, `MQTT_*`, `WORKSPACE_ROOT`, `DELEGATE_JOB_LOGS_DIR`, `PYTHONPATH`, the four `XDG_*` homes, and `AGENT_SESSIONS_YAML` were unset. `AGENT_PYTHON_BIN` pointed at the control-plane venv. The snapshot was the working directory and was not checked out.
Head, the files named in the brief plus `test_b19_headless_reconcile_fixes.py`, `test_b4_session_created.py`, and `test_issue30_stall_watch.py`: **546 passed, 2 skipped** in 1458.68s, exit 0. `tests/test_issue71_herdr_names.py` collects 70 tests and they are in that pass. The two skips, confirmed with `-rs` on those two tests, are `test_claude_skills_link_resolves_to_agents_skills` (`.claude/skills` absent, `test_issue42_skill_blocks.py:266`) and `test_s6_the_heartbeat_timer_is_not_a_background_child` (Linux `/proc`, `test_issue30_stall_watch.py:340`).
Base: a `--shared` clone at `/tmp/mamfd9-fd90992c-base`, detached at `29cc4fd`, with only this test file copied in. Same pytest flags, `--basetemp=/tmp/mamfd9-fd90992c-base-pytest`. **68 failed, 2 passed** in 98.42s, exit 1. The two passes are `test_the_names_need_converting_and_the_plain_one_does_not` and the plain-name `test_wrapper_attach_calls_herdr_with_the_herdr_name`. The long and uppercase wrapper cases failed.
## Residuals (non-blocking)
- Readers stay read-only. An old registry keeps a format-1 `attach_command` until the next writer, or until `python -m lib_py.registry_format` runs. The module docstring describes that window. The explicit entry point is left for #72.
- `VERSIONS.md` is unchanged. The release sentence from the scope comment is in the PR body.
- `migrate` skips a row with no `name` (`registry_format.py:57-58`), so that row can keep `kill_command` after the format key becomes 2.
- `lib_py/state.py` `load_state_json` still returns `{}` on a read error. Stop uses the bash strict reader.
- The SKILL.md exit-0 cell describes a stop or an already-stopped no-op. The pre-flight paragraph and the script header also describe the row-less close as exit 0. The pre-flight sentence names `--herdr-session` and `HERDR_SESSION_NAME`; the code also uses the workspace server when both are absent.
- A direct call to `migrate` on a format already greater than 2 would write the key back to 2. The writer does not call it in that case.
- S11 skips only when `0fc162e` cannot be resolved. A squash merge would take that path. The PR forbids squash for that reason.
## Host
Checked before the suite at 2026-10-27 01:43:34 and again after it at 02:08:54. No process whose first command token was `opencode`. `lsof` of the user opencode database returned exit 1. Database mtime 1792641027, size 36712448. `opencode.log` absent. Config `service.json` mtime 1792246838, size 64. State `service.json` absent. Herdr config mtime 1784456394, size 19. `~/.local/state/mam` directory mtime 1792846843, size 128. `id-order` is a directory, mtime 1792846886, size 128; its entries were not listed. The same two herdr servers were present before and after: pid 11655 (`/opt/homebrew/bin/herdr server`) and pid 69309 (`/opt/homebrew/bin/herdr --session multi-agent-mux server`). No server argv contained the scratch prefix, the snapshot path, or the loop id. The database was not opened.
After the suite the snapshot was still head `5f60653`, tree `98d57bdc`, porcelain 0, no `.mam`. The control-plane checkout was still `29cc4fd`, porcelain 0.
Rechecked after this report was written, at 2026-10-27 02:09:37. The snapshot was still head `5f60653`, tree `98d57bdc`, porcelain 0, with no `.mam`. The control plane was still `29cc4fd`, porcelain 0. Opencode first-token count 0, database `lsof` exit 1, and the database, log, service files, herdr config, `~/.local/state/mam`, and `id-order` mtimes and sizes were the same as the pre-suite reading. Herdr servers were still pid 11655 and pid 69309. No server argv contained the scratch prefix, the snapshot path, or the loop id.
[VERDICT: PASS]
+144 -58
View File
@@ -29,33 +29,54 @@ export WORKSPACE_ROOT
# Framework semantic version. Single runtime source of truth; kept in lockstep
# with VERSIONS.md and the 8 SKILL.md frontmatters by tests/test_version_consistency.py.
# NOTE: unlike other MAM_* variables this one is intentionally NOT env-overridable.
MAM_VERSION="4.10.0"
MAM_VERSION="4.11.0"
export MAM_VERSION
AGENT_SESSIONS_YAML="${AGENT_SESSIONS_YAML:-$WORKSPACE_ROOT/.mam/agent-sessions.yaml}"
# The one bash form of lib_py.agents.sanitize.sanitize_herdr_agent_name
# (Issue #71). The herdr shim gets this same definition: _init_herdr_isolation
# writes it in with `declare -f`. A long name needs its SHA-1 suffix; with no
# shasum, sha1sum, openssl or python3 to make it, this fails (stderr, nonzero)
# rather than return a name herdr never registered. A name that is not ASCII
# is lowercased and replaced by python3 (tr/sed differ from str.lower() there),
# and fails the same way without it. Line breaks become '-' before sed (which
# works line by line) and before $(...) could drop a trailing one.
_sanitize_herdr_agent_name() {
local n="$1"
if [ -z "$n" ]; then
printf 'agent\n'
return 0
fi
local s
s=$(echo "$n" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9_-]/-/g')
local s=""
if [ -z "$(printf '%s' "$n" | LC_ALL=C tr -d '\000-\177')" ]; then
s=$(printf '%s' "$n" | tr '\n' '-' | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9_-]/-/g')
elif command -v python3 >/dev/null 2>&1; then
s=$(python3 -c 'import re,sys; sys.stdout.write(re.sub(r"[^a-z0-9_-]", "-", sys.argv[1].lower()))' "$n") || s=""
fi
if [ -z "$s" ]; then
echo "ERROR: cannot make the herdr name of '$n': it is not ASCII and there is no python3 to convert it" >&2
return 1
fi
if [[ ! "$s" =~ ^[a-z] ]]; then s="x-$s"; fi
if [ "${#s}" -gt 32 ]; then
local h=""
if command -v shasum >/dev/null 2>&1; then
h=$(printf '%s' "$s" | shasum 2>/dev/null | awk '{print $1}' | cut -c 1-8)
elif command -v sha1sum >/dev/null 2>&1; then
fi
if [ "${#h}" -ne 8 ] && command -v sha1sum >/dev/null 2>&1; then
h=$(printf '%s' "$s" | sha1sum 2>/dev/null | awk '{print $1}' | cut -c 1-8)
elif command -v openssl >/dev/null 2>&1; then
fi
if [ "${#h}" -ne 8 ] && command -v openssl >/dev/null 2>&1; then
h=$(printf '%s' "$s" | openssl sha1 2>/dev/null | awk '{print $NF}' | cut -c 1-8)
fi
if [ -n "$h" ]; then
s="${s:0:23}-$h"
else
s="${s:0:32}"
if [ "${#h}" -ne 8 ] && command -v python3 >/dev/null 2>&1; then
h=$(python3 -c 'import hashlib,sys; print(hashlib.sha1(sys.argv[1].encode("utf-8")).hexdigest()[:8])' "$s" 2>/dev/null || true)
fi
if [ "${#h}" -ne 8 ]; then
echo "ERROR: cannot make the herdr name of '$n': no shasum, sha1sum, openssl or python3 to hash it" >&2
return 1
fi
s="${s:0:23}-${h}"
fi
printf '%s\n' "$s"
}
@@ -155,8 +176,10 @@ _init_herdr_isolation() {
fi
local tmp_file="$wrapper_dir/herdr.tmp.$$.$RANDOM"
cat <<'EOF' > "$tmp_file"
#!/usr/bin/env bash
{
printf '%s\n' '#!/usr/bin/env bash'
declare -f _sanitize_herdr_agent_name
cat <<'EOF'
# Herdr-to-Herdr translation shim wrapper
set -euo pipefail
@@ -290,31 +313,6 @@ _real_herdr() {
fi
}
_sanitize_herdr_agent_name() {
local n="$1"
if [ -z "$n" ]; then
printf 'agent\n'
return 0
fi
local s
s=$(echo "$n" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9_-]/-/g')
if [[ ! "$s" =~ ^[a-z] ]]; then s="x-$s"; fi
if [ "${#s}" -gt 32 ]; then
local h=""
if command -v shasum >/dev/null 2>&1; then
h=$(printf '%s' "$s" | shasum 2>/dev/null | awk '{print $1}' | cut -c 1-8)
elif command -v sha1sum >/dev/null 2>&1; then
h=$(printf '%s' "$s" | sha1sum 2>/dev/null | awk '{print $1}' | cut -c 1-8)
elif command -v openssl >/dev/null 2>&1; then
h=$(printf '%s' "$s" | openssl sha1 2>/dev/null | awk '{print $NF}' | cut -c 1-8)
else
h=$(python3 -c "import hashlib,sys; print(hashlib.sha1(sys.argv[1].encode()).hexdigest()[:8])" "$s" 2>/dev/null || echo "00000000")
fi
s="${s:0:23}-${h}"
fi
printf '%s\n' "${s:0:32}"
}
# ---------------------------------------------------------------------------
# Workspace scoping (ISSUE-3).
#
@@ -441,7 +439,7 @@ sys.exit(1)
[ -n "$resolved_ws" ] && target_ws="$resolved_ws"
fi
local sat pid=""
sat=$(_sanitize_herdr_agent_name "$target")
sat=$(_sanitize_herdr_agent_name "$target") || return 1
local cand get_ws
if [ "$#" -ge 2 ]; then
@@ -534,7 +532,7 @@ case "$cmd" in
_resolve_herdr_target() {
local raw="$1"
local sat
sat=$(_sanitize_herdr_agent_name "$raw")
sat=$(_sanitize_herdr_agent_name "$raw") || return 1
if [ -n "$(_herdr_agent_get_scoped "$sat" 2>/dev/null || true)" ]; then
echo "$sat"
return 0
@@ -604,6 +602,15 @@ sys.exit(1)
exit 1
fi
_real_herdr agent read "$tgt" "$@"
elif [ "$sub" = "attach" ] && [ $# -ge 1 ]; then
t="$1"
shift
tgt=$(_resolve_herdr_target "$t" || true)
if [ -z "$tgt" ]; then
echo "Error: no in-scope herdr agent for '$t'" >&2
exit 1
fi
_real_herdr agent attach "$tgt" "$@"
else
_real_herdr agent "$sub" "$@"
fi
@@ -623,19 +630,15 @@ sys.exit(1)
*) shift ;;
esac
done
if [ -n "$(_herdr_agent_get_scoped "$(_sanitize_herdr_agent_name "$sess")" 2>/dev/null || true)" ] \
ssess=$(_sanitize_herdr_agent_name "$sess") || exit 1
if [ -n "$(_herdr_agent_get_scoped "$ssess" 2>/dev/null || true)" ] \
|| [ -n "$(_herdr_agent_get_scoped "$sess" 2>/dev/null || true)" ]; then
exit 0
fi
if _real_herdr agent list 2>/dev/null | TARGET_NAME="$sess" TARGET_WS="$(_herdr_ws_scope)" python3 -c '
if _real_herdr agent list 2>/dev/null | TARGET_NAME="$sess" TARGET_SAN="$ssess" TARGET_WS="$(_herdr_ws_scope)" python3 -c '
import sys, json, os
try:
sys.path.insert(0, os.path.abspath(".agents/skills"))
from lib_py.agents.sanitize import sanitize_herdr_agent_name
except Exception:
sanitize_herdr_agent_name = lambda s: s.lower()[:32] if s else "agent"
tn = os.environ.get("TARGET_NAME", "")
stn = sanitize_herdr_agent_name(tn)
stn = os.environ.get("TARGET_SAN", "")
tws = os.environ.get("TARGET_WS", "")
try:
d = json.loads(sys.stdin.read())
@@ -682,6 +685,7 @@ sys.exit(1)
*) run_cmd="$1"; shift ;;
esac
done
agent_name=$(_sanitize_herdr_agent_name "$name") || exit 1
if [ -z "$run_cmd" ]; then
run_cmd="${SHELL:-/bin/bash}"
fi
@@ -896,7 +900,6 @@ except Exception:
res=""
success=0
backoffs=(0.5 1 2)
agent_name=$(_sanitize_herdr_agent_name "$name")
for i in $(seq 0 2); do
if [ -n "$final_cmd" ]; then
res=$(eval "_real_herdr agent start \"$agent_name\" --kind \"$kind\" --pane \"$target_pane\" -- $final_cmd" 2>&1 || true)
@@ -967,7 +970,7 @@ except Exception:
# `herdr session list`), not individual agent panes — `session stop/delete
# "$sess"` with a MAM session name always fails (silently, via `|| true`).
# Resolve the real pane_id via `agent get` and close just that pane instead.
agent_target=$(_sanitize_herdr_agent_name "$sess")
agent_target=$(_sanitize_herdr_agent_name "$sess") || exit 1
pane_id=$(_resolve_herdr_pane_id "$sess" 2>/dev/null || true)
if [ -n "$pane_id" ]; then
_real_herdr pane close "$pane_id" >/dev/null 2>&1 || true
@@ -1002,7 +1005,7 @@ except Exception:
# there is no top-level "pane" key, and no "pid" field at all (only
# "pane_id", herdr's own wN:pN identifier). The real OS pid requires a
# second call to `pane process-info --pane <pane_id>`.
agent_target=$(_sanitize_herdr_agent_name "$sess")
agent_target=$(_sanitize_herdr_agent_name "$sess") || exit 1
info=$(_real_herdr agent get "$agent_target" 2>/dev/null || _real_herdr agent get "$sess" 2>/dev/null || true)
pane_id="" cwd="" cmd=""
if [ -n "$info" ]; then
@@ -1072,7 +1075,7 @@ except Exception:
*) shift ;;
esac
done
agent_target=$(_sanitize_herdr_agent_name "$sess")
agent_target=$(_sanitize_herdr_agent_name "$sess") || exit 1
pane_id=$(_resolve_herdr_pane_id "$sess" 2>/dev/null || true)
if [ -n "$pane_id" ]; then
_real_herdr pane read "$pane_id" --source visible --lines 100 2>/dev/null \
@@ -1103,7 +1106,7 @@ except Exception:
fi
# `pane send-keys` requires a real pane_id ("wN:pN"), not an agent name —
# resolve it via `agent get` first (agent-level commands accept names).
agent_target=$(_sanitize_herdr_agent_name "$sess")
agent_target=$(_sanitize_herdr_agent_name "$sess") || exit 1
pane_id=$(_resolve_herdr_pane_id "$sess" 2>/dev/null || true)
if [ -n "$pane_id" ]; then
_real_herdr pane send-keys "$pane_id" "$key" >/dev/null 2>&1 || true
@@ -1387,6 +1390,7 @@ for name, pid in rows:
;;
esac
EOF
} > "$tmp_file"
chmod +x "$tmp_file" 2>/dev/null || true
mv -f "$tmp_file" "$wrapper_dir/herdr" 2>/dev/null || rm -f "$tmp_file" 2>/dev/null || true
if [[ ":$PATH:" != *":$wrapper_dir:"* ]]; then
@@ -1587,10 +1591,14 @@ herdr() {
# ---------------------------------------------------------------------------
# NOTE: Retained as small inline PYEOF block (39 lines) per Plan Rev.2 (Job 98393a97).
# Small blocks have minimal overhead and high local cohesion; extraction into lib_py is reserved for large blocks (>200 lines).
# `load_state_json strict` (Issue #71) fails (stderr, exit 1) when the YAML or
# DB cannot be read, a DB table missing included, instead of printing {} - for
# a caller that acts on a session being absent.
load_state_json() {
env_python "$AGENT_SESSIONS_YAML" <<'PYEOF'
env_python "$AGENT_SESSIONS_YAML" "MAM_STATE_READ=${1:-}" <<'PYEOF'
import os, sys, sqlite3, json, yaml
yaml_path = os.environ['YAML_PATH']
strict = os.environ.get('MAM_STATE_READ') == 'strict'
db_path = os.path.splitext(yaml_path)[0] + '.db'
d = {}
db_sessions = []
@@ -1603,20 +1611,24 @@ try:
if row:
d = json.loads(row[0])
except sqlite3.OperationalError:
pass
if strict:
raise
try:
cursor = conn.execute('SELECT data FROM sessions')
for r in cursor.fetchall():
db_sessions.append(json.loads(r[0]))
d['herdr_sessions'] = db_sessions
except sqlite3.OperationalError:
pass
if strict:
raise
conn.close()
elif os.path.exists(yaml_path):
with open(yaml_path) as f:
d = yaml.safe_load(f) or {}
except Exception:
pass
except Exception as e:
if strict:
print('ERROR: cannot read the registry %s: %s' % (yaml_path, e), file=sys.stderr)
sys.exit(1)
# Clean lone surrogates to prevent UnicodeEncodeError on stdout print
def clean_surrogates(obj):
if isinstance(obj, str):
@@ -1659,6 +1671,22 @@ print(resolved)
"
}
# session_attach_command <session_name> [server]
#
# Issue #72: the attach hint a script prints - the session row's
# attach_command, else the one lib_py.agents.sanitize.attach_command builds for
# <server> (default: `default`).
session_attach_command() {
MAM_STATE_JSON="$(load_state_json)" SESSION_NAME="$1" ATTACH_SERVER="${2:-default}" python3 -c "
import os, json
from lib_py.agents.sanitize import attach_command
name = os.environ['SESSION_NAME']
d = json.loads(os.environ.get('MAM_STATE_JSON') or '{}')
row = next((s for s in d.get('herdr_sessions', []) if s.get('name') == name), {})
print(row.get('attach_command') or attach_command(os.environ['ATTACH_SERVER'], name))
"
}
# resolve_herdr_session <session_name> [workspace]
#
# returns the isolated herdr *session* name (socket/daemon) to use for this MAM session row,
@@ -2787,7 +2815,7 @@ wait_for_agent_idle() {
fi
local target
target=$(_sanitize_herdr_agent_name "$sess")
target=$(_sanitize_herdr_agent_name "$sess") || return 3
local raw_status="" agent_st=""
raw_status="$(_sks_herdr agent get "$target" 2>/dev/null || _sks_herdr agent get "$sess" 2>/dev/null || true)"
@@ -2912,7 +2940,7 @@ send_keys_safe() {
fi
local agent_target
agent_target=$(_sanitize_herdr_agent_name "$sess")
agent_target=$(_sanitize_herdr_agent_name "$sess") || return 1
# Busy gate: wait up to SKS_BUSY_TIMEOUT only if wait_for_agent_idle returned 3 (unknown)
if [ "${_SKS_IDLE_RC:-0}" = "3" ]; then
@@ -3219,3 +3247,61 @@ mam_control_plane_prefix() {
[ -n "${1:-}" ] || return 0
PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" "$(_delegate_py_bin)" -m lib_py.control_plane prefix "$1"
}
# ---------------------------------------------------------------------------
# Issue #72 (decision 8): the update admission gate.
#
# The update skill publishes .mam/update-in-progress (pid + lstart, loop_lock.sh
# rules) while it holds .mam/update-admission.lock exclusively, then runs its
# pre-check. Every entry that starts something the pre-check must see - create,
# resume, run_loop.sh, delegate submit - or writes the registry - stop,
# orc-onboard - takes that lock shared on fd 7, refuses while the marker is
# `live` or `unknown`, and keeps fd 7 until its record is written
# (mam_update_admit_release). An update therefore either waits for that record
# and sees it, or publishes first and the entry refuses. The lock is a BSD
# flock taken by lib_py.pane_lock on the open fd: it ends when every process
# holding fd 7 has ended, however that ends, so nothing long-lived may inherit
# fd 7 (`7>&-` on such a child, as pane_lock's fd 9). The wait is bounded by
# MAM_UPDATE_ADMISSION_WAIT_S (default 120).
#
# mam_update_admit [workspace [fd]]: rc 0 admitted (fd 7, or the given one,
# held unless the workspace has no .mam), 1 refused (stderr). Delegate submit
# also takes the workspaces its target is in on fds 20 and up.
mam_update_admit() {
local root="${1:-$WORKSPACE_ROOT}" fd="${2:-7}"
local lock="$root/.mam/update-admission.lock" marker="$root/.mam/update-in-progress" st
# No .mam: not an installed workspace, which is all the update runs in (and a
# checkout running its own scripts gets no .mam from this).
[ -d "$root/.mam" ] || return 0
if ! eval "exec $fd>>\"\$lock\""; then
echo "ERROR: cannot open the update admission lock $lock" >&2
return 1
fi
case "$(PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" python3 -m lib_py.pane_lock acquire --fd "$fd" --shared --wait-env MAM_UPDATE_ADMISSION_WAIT_S)" in
locked) ;;
timeout)
mam_update_admit_release "$fd"
echo "ERROR: an update of $root holds its admission lock (MAM_UPDATE_ADMISSION_WAIT_S passed); try again when it has finished" >&2
return 1 ;;
*)
mam_update_admit_release "$fd"
echo "ERROR: the update admission lock $lock could not be decided (lib_py.pane_lock)" >&2
return 1 ;;
esac
[ -f "$marker" ] || return 0
# shellcheck disable=SC1091
declare -F _mam_lock_holder_state >/dev/null || source "$SKILL_DIR/multi-agent-mux-loop/scripts/loop_lock.sh"
st="$(_mam_lock_holder_state "$marker")"
case "$st" in
stale)
echo "NOTE: stale update marker $marker (its update is gone); ignored" >&2
return 0 ;;
esac
mam_update_admit_release "$fd"
echo "ERROR: an update of $root is in progress (marker $marker, holder $st); try again when it has finished" >&2
return 1
}
mam_update_admit_release() {
eval "exec ${1:-7}>&-"
}
+2 -1
View File
@@ -341,7 +341,8 @@ def wait(cwd: str, agent: str, session: str, owner_pid: int,
print(
f"ERROR: {m['session']}'s create/resume exited before recording it, "
f"and {seen}; close it with: "
f"HERDR_SESSION_NAME={srv} herdr kill-session -t {m['session']}",
f"bash .agents/skills/multi-agent-mux-stop/scripts/stop_session.sh "
f"--session {m['session']} --herdr-session {srv}",
file=sys.stderr, flush=True,
)
return 1
+12
View File
@@ -1,5 +1,6 @@
import hashlib
import re
import shlex
def sanitize_herdr_agent_name(name: str) -> str:
"""
@@ -29,3 +30,14 @@ def sanitize_herdr_agent_name(name: str) -> str:
return s
def attach_command(server, name):
"""The registry row's attach_command (Issue #71).
A native herdr command, so it works from any shell: the server is always
named (`default` too - herdr's env-free default), and the agent by the name
herdr registered (sanitize_herdr_agent_name). create, resume and reconcile
all write it from here.
"""
return "herdr --session %s agent attach %s" % (
shlex.quote(server or "default"), shlex.quote(sanitize_herdr_agent_name(name)))
+24 -1
View File
@@ -4,6 +4,7 @@
import os, sys, tempfile, shutil, glob, subprocess, json, sqlite3, fcntl
from datetime import datetime, timezone
import yaml
from lib_py import registry_format
def atomic_dump_yaml_main():
yaml_path = os.environ['YAML_PATH']
@@ -114,6 +115,27 @@ def atomic_dump_yaml_main():
elif 'herdr_sessions' not in d:
d['herdr_sessions'] = []
# Issue #71: an older registry is migrated here, under the lock and
# before the caller's mutation. It changes row fields only, so the
# YAML is rewritten whether or not the mutation changes anything.
from_version = registry_format.stored_version(d)
migrated_rows = 0
force_yaml = False
if from_version < registry_format.CURRENT:
d, migrated_rows = registry_format.migrate(d)
force_yaml = True
elif row:
# The DB is current; its YAML copy may not be (a migration whose
# YAML rewrite failed after the DB commit). Then rewrite it.
y = None
if os.path.exists(yaml_path):
try:
with open(yaml_path) as f:
y = yaml.safe_load(f)
except Exception:
y = None
force_yaml = registry_format.stored_version(y) < registry_format.CURRENT
old_sessions = get_all_sessions_status(d)
# a mutation that replaces a row the monitor registered for a pane it
# created itself (create_session.sh) drops that name from old_roles
@@ -167,7 +189,7 @@ def atomic_dump_yaml_main():
conn.commit()
if new_sessions != old_sessions:
if force_yaml or new_sessions != old_sessions:
if os.path.exists(yaml_path):
try:
shutil.copy2(yaml_path, yaml_path + '.bak')
@@ -207,6 +229,7 @@ def atomic_dump_yaml_main():
_flock_f.close()
except Exception:
pass
return from_version, registry_format.stored_version(d), migrated_rows
if __name__ == '__main__':
atomic_dump_yaml_main()
+1 -1
View File
@@ -13,7 +13,7 @@ import sys
from typing import List, Sequence
# Framework version locked with framework constants
FRAMEWORK_VERSION = "4.10.0"
FRAMEWORK_VERSION = "4.11.0"
CORE_MODULES: Sequence[str] = (
# Standard library modules
+10 -5
View File
@@ -18,6 +18,9 @@ one lock per server from the choice to the claim.
pane_lock.py acquire --fd N flock(LOCK_EX) on the inherited fd N; prints
`locked`, or `timeout` once MAM_PANE_CLAIM_WAIT_S
(default 120, minimum 1) has passed
[--shared] [--wait-env VAR] Issue #72: LOCK_SH instead, and the wait read
from VAR (same default and minimum) - the
update admission lock (lib.sh mam_update_admit)
The lock is a BSD flock, which belongs to the open file on fd 9: it outlives
this helper and ends when the last process holding that fd ends, however it
@@ -63,11 +66,11 @@ def lock_path(session: Optional[str], env: Mapping[str, str]) -> str:
return os.path.join(home, ".local", "state", "mam", "pane-lock", session + ".lock")
def wait_s(env: Mapping[str, str]) -> int:
return ints.positive_int(env.get("MAM_PANE_CLAIM_WAIT_S"), 1) or DEFAULT_WAIT_S
def wait_s(env: Mapping[str, str], var: str = "MAM_PANE_CLAIM_WAIT_S") -> int:
return ints.positive_int(env.get(var), 1) or DEFAULT_WAIT_S
def acquire(fd: int, wait: float) -> str:
def acquire(fd: int, wait: float, shared: bool = False) -> str:
"""`locked`, or `timeout` when the lock was not free within `wait` s. The
deadline is on the monotonic clock; a step of the wall clock does not
stretch or cut it. Only contention is waited out: any other error (a bad
@@ -75,7 +78,7 @@ def acquire(fd: int, wait: float) -> str:
deadline = time.monotonic() + wait
while True:
try:
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
fcntl.flock(fd, (fcntl.LOCK_SH if shared else fcntl.LOCK_EX) | fcntl.LOCK_NB)
return "locked"
except BlockingIOError:
if time.monotonic() >= deadline:
@@ -90,11 +93,13 @@ def main(argv: Optional[List[str]] = None) -> int:
s.add_argument("--session", default="")
s = sub.add_parser("acquire")
s.add_argument("--fd", type=int, required=True)
s.add_argument("--shared", action="store_true")
s.add_argument("--wait-env", default="MAM_PANE_CLAIM_WAIT_S")
a = p.parse_args(argv)
if a.cmd == "path":
print(lock_path(a.session, os.environ))
else:
print(acquire(a.fd, wait_s(os.environ)))
print(acquire(a.fd, wait_s(os.environ, a.wait_env), a.shared))
return 0
+92
View File
@@ -0,0 +1,92 @@
"""Registry format version and its migration (Issue #71).
The session registry (agent-sessions.yaml + agent-sessions.db) carries a
top-level `registry_format` key; it lives in the DB `state` row like every
other top-level key. A registry without it is format 1.
1: rows may carry `kill_command` and `start_command`, and `attach_command`
reads `HERDR_SESSION_NAME=<server> herdr agent attach <original name>`.
2: no `kill_command` / `start_command`; `attach_command` is
lib_py.agents.sanitize.attach_command(<row server>, <row name>).
The writer (lib_py.atomic_yaml, the only code that writes the registry)
migrates an older registry under its lock before the caller's mutation, and
then rewrites the YAML as well as the DB. Readers (lib_py.state) never
migrate; they see the old rows until the first write.
Entry point (for #72, the update skill):
python -m lib_py.registry_format --yaml <agent-sessions.yaml>
runs that migration through the writer (same lock, YAML rewritten), prints one
line `registry-format: <from> -> <to>, rows changed: <n>` and exits 0 when the
registry was migrated or already current, nonzero on error. Run it with the
skills directory on PYTHONPATH (as lib.sh does), e.g.
`PYTHONPATH=.agents/skills python3 -m lib_py.registry_format --yaml .mam/agent-sessions.yaml`.
"""
import argparse
import copy
import os
import sys
from lib_py.agents.sanitize import attach_command
FORMAT_KEY = "registry_format"
CURRENT = 2
_DROPPED = ("kill_command", "start_command")
def stored_version(d):
"""The format a registry dict says it has (1 when it says nothing)."""
v = d.get(FORMAT_KEY) if isinstance(d, dict) else None
return v if isinstance(v, int) and not isinstance(v, bool) else 1
def _row_server(s):
return s.get("herdr_session") or s.get("herdr_server") or "default"
def migrate(d):
"""Return (new registry dict, rows changed). Pure and idempotent: `d` is
not modified, and migrating the result again changes nothing. Every row is
migrated, `stopped` and `terminated` ones included."""
out = copy.deepcopy(d) if isinstance(d, dict) else {}
changed = 0
for s in out.get("herdr_sessions") or []:
if not isinstance(s, dict) or not s.get("name"):
continue
before = dict(s)
for k in _DROPPED:
s.pop(k, None)
s["attach_command"] = attach_command(_row_server(s), s["name"])
if s != before:
changed += 1
out[FORMAT_KEY] = CURRENT
return out, changed
def main(argv=None):
ap = argparse.ArgumentParser(prog="python -m lib_py.registry_format",
description="Migrate the session registry to the current format.")
ap.add_argument("--yaml", required=True, help="path of agent-sessions.yaml")
a = ap.parse_args(argv)
yaml_path = os.path.abspath(a.yaml)
db_path = os.path.splitext(yaml_path)[0] + ".db"
if not (os.path.exists(yaml_path) or os.path.exists(db_path)):
print("registry-format: error: no registry at %s" % yaml_path, file=sys.stderr)
return 1
from lib_py.atomic_yaml import atomic_dump_yaml_main
os.environ["YAML_PATH"] = yaml_path
os.environ["AGENT_SESSIONS_MUTATION"] = "pass"
try:
frm, to, n = atomic_dump_yaml_main()
except (Exception, SystemExit) as exc: # the writer refuses with SystemExit
print("registry-format: error: %s" % (exc,), file=sys.stderr)
return 1
print("registry-format: %d -> %d, rows changed: %d" % (frm, to, n), flush=True)
return 0
if __name__ == "__main__":
sys.exit(main())
+13 -7
View File
@@ -38,6 +38,7 @@ from typing import List, Optional
from lib_py.agents.input_region import extract_input_region
from lib_py.agents.registry import agent_of_row, get_adapter
from lib_py.agents.sanitize import sanitize_herdr_agent_name
from lib_py.delegation.engine import TERMINAL_STATUSES
from lib_py.loop import watcher
@@ -154,8 +155,12 @@ class HerdrError(Exception):
class HerdrSource(object):
"""Every herdr and OS read the judgement needs, in one place."""
def __init__(self, target, agent, session=None):
def __init__(self, target, agent, session=None, *, name):
self.target, self.session = target, session or None
# `name` is the name herdr knows the agent by (#66), worked out once
# by StallWatch (#71); the kind is read off the original name, whose
# suffix a long name's hash replaces.
self.name = name
self.agent = resolve_agent(agent, target)
adapter = get_adapter(self.agent) if self.agent else None
self.resident = tuple(adapter.resident_children) if adapter else ()
@@ -176,7 +181,7 @@ class HerdrSource(object):
return proc.stdout
def agent_get(self):
payload = json.loads(self._herdr("agent", "get", self.target) or "{}")
payload = json.loads(self._herdr("agent", "get", self.name) or "{}")
return (payload.get("result") or {}).get("agent") or {}
def agent_pid(self, pane_id):
@@ -199,12 +204,12 @@ class HerdrSource(object):
if not self.agent:
return None
try:
return extract_input_region(self._herdr("agent", "read", self.target), self.agent) == ""
return extract_input_region(self._herdr("agent", "read", self.name), self.agent) == ""
except ValueError:
return None
def prompt(self, text):
self._herdr("agent", "prompt", self.target, text)
self._herdr("agent", "prompt", self.name, text)
def collect(self, sig: Signals) -> Signals:
"""Fill the process and input signals of an idle agent."""
@@ -221,7 +226,8 @@ class HerdrSource(object):
class StallWatch(object):
def __init__(self, args, source=None):
self.args = args
self.src = source or HerdrSource(args.target, args.agent, args.herdr_session)
self.herdr_name = sanitize_herdr_agent_name(args.target) # for herdr only; logs keep args.target
self.src = source or HerdrSource(args.target, args.agent, args.herdr_session, name=self.herdr_name)
name = resolve_agent(args.agent, args.target)
adapter = get_adapter(name) if name else None
reliable = bool(adapter.agent_status_reliable) if adapter else True
@@ -323,7 +329,7 @@ class StallWatch(object):
until, tmo = ("idle", "done", "working"), 60000
else:
until, tmo = ("idle", "done", "blocked"), 60000
out = watcher.wait_once(self.args.target, until=until, timeout_ms=tmo, session=self.args.herdr_session)
out = watcher.wait_once(self.herdr_name, until=until, timeout_ms=tmo, session=self.args.herdr_session)
if self._wrapper_gone(): # round-2 review D2: before any notice
return RC_TERMINAL
if out.kind in (watcher.TRANSPORT_ERROR, watcher.AGENT_NOT_FOUND):
@@ -425,7 +431,7 @@ def main(argv=None) -> int:
p = argparse.ArgumentParser(description="notify-only stall watcher for one local herdr job (#30)")
p.add_argument("--job", required=True)
p.add_argument("--registry-dir", required=True)
p.add_argument("--target", required=True, help="herdr agent name of the job's session")
p.add_argument("--target", required=True, help="session name of the job (herdr is called with its sanitized form)")
p.add_argument("--agent", required=True, help="agent CLI (adapter name)")
p.add_argument("--herdr-session", default="")
p.add_argument("--expect-dispatch-token", default=None)
+410
View File
@@ -0,0 +1,410 @@
"""Decisions behind the multi-agent-mux-update skill (Issue #72).
update.sh keeps the orchestration in shell (phase 1: this tree's code) and asks
this module what needs parsing. Standard library only, Python 3.9.
python -m lib_py.update precheck --workspace W --herdr H
What refuses the update (comment 921, decisions 1 and 7): the running
rows of the registry (MAM_STATE_JSON, from lib.sh `load_state_json
strict`), every live herdr agent whose cwd is inside W on any server
`herdr session list --json` reports running (read only: the real herdr H,
never the shim, so no server is started), and the open jobs. The agent
that called the update - by (server, pane): pane_lock.caller_server and
HERDR_PANE_ID - is not counted, nor its row and its one running job.
Prints one line per blocker with how to clear it; rc 0 nothing blocks,
1 something does (a herdr or registry that cannot be read included).
python -m lib_py.update monitor --workspace W
JSON {"pids": [...], "lock": "free"|"held"}: the monitor daemons
daemon.find_running recognises, and whether .mam/monitor.lock is held.
python -m lib_py.update target-workspaces --workspace W --agent-session S --server V --herdr H
The workspaces other than W whose update admission a delegate submit
to `herdr:<name>` must take (decision 8), one per line: every installed
workspace at or above the target's cwd (its row's pane.cwd; with no row
the live agent's on server V) and its row's control_plane
workspace_root. Nothing for a non-herdr target. rc 1: herdr cannot
answer for a rowless target.
python -m lib_py.update backup-registry --workspace W --dest D
Copies the registry into D under the registry's own write lock
(agent-sessions.db.lock, as atomic_yaml takes it): the DB through the
SQLite backup API (commits still in -wal included), the YAML and its
.bak as files. Prints the names it copied.
"""
from __future__ import annotations
import argparse
import fcntl
import json
import os
import shlex
import shutil
import sqlite3
import subprocess
import sys
from typing import Dict, Iterable, List, Mapping, Optional, Tuple
from lib_py.agents.sanitize import sanitize_herdr_agent_name
from lib_py.delegation import daemon
from lib_py.delegation.engine import TERMINAL_STATUSES
from lib_py.pane_lock import caller_server
REGISTRY_FILES = ("agent-sessions.yaml", "agent-sessions.yaml.bak", "agent-sessions.db")
HERDR_TIMEOUT_S = 20
def _real(p: str) -> str:
return os.path.realpath(p)
def _inside(path: str, root: str) -> bool:
if not path:
return False
p, r = _real(path), _real(root).rstrip(os.sep)
return p == r or p.startswith(r + os.sep)
def row_server(row: dict) -> str:
return row.get("herdr_session") or row.get("herdr_server") or "default"
# --------------------------------------------------------------------------- herdr
class HerdrError(Exception):
pass
def _herdr_json(herdr: str, args: List[str], env: Mapping[str, str]) -> dict:
# A pane's own scope ids would narrow the answer to the caller's workspace.
cenv = {k: v for k, v in env.items()
if k not in ("HERDR_WORKSPACE_ID", "HERDR_PANE_ID", "HERDR_TAB_ID")}
try:
res = subprocess.run([herdr, *args], env=cenv, stdin=subprocess.DEVNULL,
capture_output=True, text=True, timeout=HERDR_TIMEOUT_S)
except (OSError, subprocess.SubprocessError) as exc:
raise HerdrError("`herdr %s` failed: %s" % (" ".join(args), exc))
if res.returncode != 0:
raise HerdrError("`herdr %s` exited %d: %s" % (" ".join(args), res.returncode,
(res.stderr or "").strip()[:200]))
try:
out = res.stdout
return json.loads(out[out.index("{"):])
except ValueError:
raise HerdrError("`herdr %s` printed no JSON" % " ".join(args))
def running_servers(herdr: str, env: Mapping[str, str]) -> List[str]:
d = _herdr_json(herdr, ["session", "list", "--json"], env)
sessions = d.get("sessions")
if not isinstance(sessions, list):
raise HerdrError("`herdr session list --json` has no sessions list")
return [s["name"] for s in sessions
if isinstance(s, dict) and s.get("running") and s.get("name")]
def server_agents(herdr: str, server: str, env: Mapping[str, str]) -> List[dict]:
d = _herdr_json(herdr, ["--session", server, "agent", "list"], env)
agents = (d.get("result") or {}).get("agents")
if not isinstance(agents, list):
raise HerdrError("`herdr --session %s agent list` has no agents list" % server)
return [a for a in agents if isinstance(a, dict)]
# --------------------------------------------------------------------------- jobs
def _job_records(d: str) -> Iterable[Tuple[str, Optional[dict]]]:
"""(path, record) per *.json; (path, None) when it cannot be read, and
(d, None) when d itself cannot be listed (not an empty registry)."""
try:
names = sorted(n for n in os.listdir(d) if n.endswith(".json") and not n.startswith("."))
except OSError:
yield d, None
return
for p in (os.path.join(d, n) for n in names):
try:
with open(p, encoding="utf-8") as fh:
rec = json.load(fh)
except (OSError, ValueError):
yield p, None
continue
yield p, rec if isinstance(rec, dict) else None
def _is_jobs_dir(d: str) -> bool:
"""d is a directory, or cannot be looked at (an unreadable parent): only an
absent d (or a file) is skipped."""
try:
os.stat(d)
except (FileNotFoundError, NotADirectoryError):
return False
except OSError:
return True
return os.path.isdir(d)
def _is_open(rec: dict) -> bool:
return rec.get("status") not in TERMINAL_STATUSES and rec.get("dry_run") is not True
def open_jobs(workspace: str, state: dict, env: Mapping[str, str]) -> List[dict]:
"""Every open record (not completed/error/cancelled, not dry_run) in this
workspace's .mam/jobs and MAM_JOBS_DIR; in another row's
control_plane.mam_jobs_dir only those addressed to a row of this registry.
A record, or a directory, that cannot be read is open (path only)."""
own, seen = [], set()
for c in (os.path.join(workspace, ".mam", "jobs"), env.get("MAM_JOBS_DIR") or ""):
if c and _is_jobs_dir(c) and _real(c) not in seen:
seen.add(_real(c))
own.append(c)
rows = state.get("herdr_sessions") or []
names = {"herdr:%s" % s.get("name") for s in rows if s.get("name")}
other = []
for s in rows:
c = (s.get("control_plane") or {}).get("mam_jobs_dir") or ""
if c and _is_jobs_dir(c) and _real(c) not in seen:
seen.add(_real(c))
other.append(c)
out = []
for dirs, addressed_only in ((own, False), (other, True)):
for d in dirs:
for path, rec in _job_records(d):
if rec is None:
out.append({"path": path, "unreadable": True, "dir": path == d})
elif _is_open(rec) and (not addressed_only or rec.get("agent_session") in names):
out.append({"path": path, "job_id": rec.get("job_id") or "",
"agent_session": rec.get("agent_session") or "",
"status": rec.get("status"), "registry_dir": d})
return out
# --------------------------------------------------------------------------- precheck
def precheck(workspace: str, state: dict, herdr: str, env: Mapping[str, str]) -> List[str]:
"""The lines that refuse the update; [] when nothing does."""
blockers: List[str] = []
stop = ("bash .agents/skills/multi-agent-mux-stop/scripts/stop_session.sh --session %s"
" (resume it after the update with multi-agent-mux-resume)")
# A rowless agent: stop finds no row to take its server from (#71).
stop_rowless = ("bash .agents/skills/multi-agent-mux-stop/scripts/stop_session.sh --session %s"
" --herdr-session %s")
# The caller (decision 7): its server and pane, both or nothing.
me_server, me_pane = caller_server(env), env.get("HERDR_PANE_ID") or ""
me_name: Optional[str] = None
me_found = False
agents_by_server: Dict[str, List[dict]] = {}
try:
for server in running_servers(herdr, env):
agents_by_server[server] = server_agents(herdr, server, env)
except HerdrError as exc:
blockers.append("herdr: cannot list the live agents (%s); fix herdr and try again" % exc)
if me_server and me_pane:
for a in agents_by_server.get(me_server, []):
if a.get("pane_id") == me_pane:
me_found = True
me_name = a.get("name") or None # a nameless pane is excluded by pane only
break
rows = state.get("herdr_sessions") or []
me_row = None
if me_name:
for s in rows:
if (s.get("status") == "running" and row_server(s) == me_server
and s.get("name") and sanitize_herdr_agent_name(s["name"]) == me_name):
me_row = s
break
for s in rows:
if s.get("status") == "running" and s is not me_row:
blockers.append("session %s is running (server %s); stop it: %s"
% (s.get("name"), row_server(s), stop % shlex.quote(s.get("name") or "")))
row_keys = {(row_server(s), sanitize_herdr_agent_name(s["name"]))
for s in rows if s.get("status") == "running" and s.get("name")}
for server, agents in agents_by_server.items():
for a in agents:
if me_found and server == me_server and a.get("pane_id") == me_pane:
continue
if not (_inside(a.get("cwd") or "", workspace)
or _inside(a.get("foreground_cwd") or "", workspace)):
continue
name = a.get("name") or ""
if name and (server, name) in row_keys:
continue # already listed as its running row
blockers.append("herdr agent %s (pane %s, server %s) runs in %s; stop it: %s"
% (name or "<unnamed>", a.get("pane_id"), server, a.get("cwd"),
stop_rowless % (shlex.quote(name), shlex.quote(server)) if name else
"herdr --session %s pane close %s"
% (shlex.quote(server), shlex.quote(a.get("pane_id") or ""))))
jobs = open_jobs(workspace, state, env)
if me_row is not None:
mine = [j for j in jobs if not j.get("unreadable")
and j.get("agent_session") == "herdr:%s" % me_row.get("name")
and j.get("status") == "running"]
if len(mine) == 1:
jobs = [j for j in jobs if j is not mine[0]]
for j in jobs:
if j.get("dir"):
blockers.append("jobs directory %s cannot be listed; fix its permissions, then try again"
% j["path"])
elif j.get("unreadable"):
blockers.append("job record %s cannot be read; check it, then move or delete it by hand"
% j["path"])
else:
blockers.append("job %s (%s, %s) is open; wait for it to end or close it: python "
".agents/skills/multi-agent-mux-delegate-job/scripts/registry.py"
" --registry-dir %s close --job %s --request cancelled"
% (j["job_id"], j["agent_session"] or "-", j["status"],
j["registry_dir"], j["job_id"]))
if blockers and not me_found:
blockers.append("note: no herdr agent was found at the caller's server %r and pane %r, so"
" nothing was excluded as the calling agent" % (me_server, me_pane))
return blockers
# --------------------------------------------------------------------------- delegate target
def _installed(d: str) -> bool:
"""update.sh's own test of an installed workspace."""
return os.path.isfile(os.path.join(d, ".mam_deploy", "remove.sh")) or os.path.isfile(os.path.join(d, "remove.sh"))
def target_workspaces(workspace: str, state: dict, agent_session: str, server: str,
herdr: str, env: Mapping[str, str]) -> List[str]:
"""Every installed workspace at or above the target's cwd - an update of
any of them counts that agent (decision 1) - and its row's
control_plane.workspace_root; the caller's own workspace left out. With
no row the cwd is the live agent's on <server> (the one delivery uses),
read with the real herdr. No herdr, a server that is not running, or no
such agent gives nothing (delivery then fails on its own); a herdr that
cannot answer raises HerdrError."""
if not agent_session.startswith("herdr:"):
return []
name = agent_session[len("herdr:"):]
row = next((s for s in state.get("herdr_sessions") or []
if isinstance(s, dict) and s.get("name") == name), None)
cwds, out = [], []
if row is not None:
cwds.append((row.get("pane") or {}).get("cwd") or "")
root = (row.get("control_plane") or {}).get("workspace_root") or ""
if root:
out.append(_real(root))
elif herdr and server in running_servers(herdr, env):
for a in server_agents(herdr, server, env):
if a.get("name") in (name, sanitize_herdr_agent_name(name)):
cwds += [a.get("cwd") or "", a.get("foreground_cwd") or ""]
break
for cwd in cwds:
d = _real(cwd) if cwd else ""
while d:
if _installed(d):
out.append(d)
if os.path.dirname(d) == d:
break
d = os.path.dirname(d)
me = _real(workspace)
return [d for i, d in enumerate(out) if d != me and d not in out[:i]]
# --------------------------------------------------------------------------- monitor
def monitor_state(workspace: str) -> dict:
pids = daemon.find_running(daemon.monitor_script_path(workspace))
lock = os.path.join(workspace, ".mam", "monitor.lock")
held = False
if os.path.exists(lock):
with open(lock, "a") as fh:
try:
fcntl.flock(fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
fcntl.flock(fh, fcntl.LOCK_UN)
except BlockingIOError:
held = True
return {"pids": pids, "lock": "held" if held else "free"}
# --------------------------------------------------------------------------- registry backup
def backup_registry(workspace: str, dest: str) -> List[str]:
mam = os.path.join(workspace, ".mam")
db = os.path.join(mam, "agent-sessions.db")
os.makedirs(dest, exist_ok=True)
copied = []
with open(db + ".lock", "w") as lk:
fcntl.flock(lk, fcntl.LOCK_EX)
try:
if os.path.exists(db):
src = sqlite3.connect(db, timeout=60.0)
dst = sqlite3.connect(os.path.join(dest, "agent-sessions.db"))
try:
src.backup(dst)
finally:
dst.close()
src.close()
copied.append("agent-sessions.db")
for name in REGISTRY_FILES:
p = os.path.join(mam, name)
if name != "agent-sessions.db" and os.path.exists(p):
shutil.copy2(p, os.path.join(dest, name))
copied.append(name)
finally:
fcntl.flock(lk, fcntl.LOCK_UN)
return copied
def _state() -> dict:
return json.loads(os.environ.get("MAM_STATE_JSON") or "{}") or {}
def main(argv: Optional[List[str]] = None) -> int:
p = argparse.ArgumentParser(prog="lib_py.update")
sub = p.add_subparsers(dest="cmd", required=True)
s = sub.add_parser("precheck")
s.add_argument("--workspace", required=True)
s.add_argument("--herdr", required=True)
s = sub.add_parser("monitor")
s.add_argument("--workspace", required=True)
s = sub.add_parser("target-workspaces")
s.add_argument("--workspace", required=True)
s.add_argument("--agent-session", required=True)
s.add_argument("--server", default="default")
s.add_argument("--herdr", default="")
s = sub.add_parser("backup-registry")
s.add_argument("--workspace", required=True)
s.add_argument("--dest", required=True)
a = p.parse_args(argv)
ws = _real(a.workspace)
if a.cmd == "precheck":
try:
state = _state()
except ValueError as exc:
print("registry: cannot parse MAM_STATE_JSON (%s)" % exc)
return 1
lines = precheck(ws, state, a.herdr, os.environ)
for line in lines:
print(line)
return 1 if lines else 0
if a.cmd == "target-workspaces":
try:
state = _state()
except ValueError:
state = {}
try:
roots = target_workspaces(ws, state, a.agent_session, a.server, a.herdr, os.environ)
except HerdrError as exc:
print("cannot find %s live: %s" % (a.agent_session, exc), file=sys.stderr)
return 1
for root in roots:
print(root)
return 0
if a.cmd == "monitor":
print(json.dumps(monitor_state(ws)))
return 0
for name in backup_registry(ws, a.dest):
print(name)
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -1,7 +1,7 @@
---
name: multi-agent-mux-create
description: "Create a new agent session (claude, antigravity/agy) in a dedicated herdr session for context-preserving long-running work. Always creates a herdr session — never backgrounds with nohup/disown. Writes the new session to .mam/agent-sessions.yaml. Use when you want to start a fresh agent (no prior UUID) for a new project workspace."
version: 4.10.0
version: 4.11.0
author: godopu
license: MIT
platforms: [linux, macos]
@@ -146,12 +146,10 @@ herdr_sessions:
cmd: claude
cmd_full: claude --dangerously-skip-permissions
cwd: /path/to/project
start_command: "HERDR_SESSION_NAME=<herdr_session> herdr new-session -d -s <SESSION_NAME> -x 140 -y 40 -c <WORKSPACE> <CMD_FULL>"
attach_command: "HERDR_SESSION_NAME=<herdr_session> herdr agent attach <SESSION_NAME>"
kill_command: "HERDR_SESSION_NAME=<herdr_session> herdr kill-session -t <SESSION_NAME>"
attach_command: "herdr --session <herdr_session> agent attach <herdr name of SESSION_NAME>"
```
The three commands require `source .agents/skills/lib.sh` first — `new-session`/`kill-session` are tmux-compat pseudo-commands the shim translates, and `HERDR_SESSION_NAME` is what the shim reads to route to the right isolated herdr *session* (real `herdr` has no env-var-based scoping of its own; `herdr_session: default` needs no prefix at all).
`attach_command` is a native herdr command and runs from any shell: it always names the server (`default` too) and names the agent as herdr registered it (lower case, at most 32 characters: `lib_py/agents/sanitize.py`). Start and stop sessions only through the create, resume and stop skills.
## Workflow
@@ -183,7 +181,7 @@ bash .agents/skills/multi-agent-mux-create/scripts/create_session.sh --workspace
| Exit | Meaning |
|---|---|
| 0 | Created; or `--dry-run` |
| 1 | Pre-flight failure, nothing was spawned: workspace not a directory, herdr not installed, agent CLI not in `PATH`, agent not authenticated, or a same-cwd session of the same Class A agent did not record its conversation ID within `MAM_ID_ORDER_WAIT_S` (default 600). After spawn: the task brief could not be written or the instruction injection failed (the session is rolled back) |
| 1 | Pre-flight failure, nothing was spawned: workspace not a directory, herdr not installed, agent CLI not in `PATH`, agent not authenticated, or a same-cwd session of the same Class A agent did not record its conversation ID within `MAM_ID_ORDER_WAIT_S` (default 600), or an update of the workspace is in progress (`multi-agent-mux-update`). After spawn: the task brief could not be written or the instruction injection failed (the session is rolled back) |
| 2 | Bad arguments; control-plane binding refused for a worktree; whitespace in a control-plane path that must be forwarded |
| 3 | A herdr session of that name already exists (`multi-agent-mux-resume`, or `multi-agent-mux-stop` first) |
| 4 | The YAML already has a `running` row of that name. The YAML write raises it after spawn, and the herdr session is rolled back. A row the monitor registered for this very pane while it was being created is replaced instead |
@@ -258,5 +256,5 @@ herdr agent read "$SESSION_NAME" --source visible --lines 20 # TUI ready = age
- **Resuming an old conversation** → `multi-agent-mux-resume`
- **Killing an existing session** → `multi-agent-mux-stop`
- **Just attaching to an existing session** → `herdr agent attach <name>` (no skill needed)
- **Just attaching to an existing session** → the row's `attach_command` (no skill needed)
- **One-shot print mode (claude -p "...")** → no herdr needed; use `claude-code` skill's print mode
@@ -260,7 +260,7 @@ spawn() {
SESSION_UUID=""
CMD_FULL="$("$(_delegate_py_bin)" -m lib_py.agents spawn-spec "$AGENT" "$RESOLVED_BIN" "" "true" 2>/dev/null || echo "${RESOLVED_BIN} --dangerously-skip-permissions")"
[ -z "$CP_PREFIX" ] || echo "WARNING: the ~/.local/bin wrapper path bypasses herdr; the control-plane binding is recorded but not forwarded" >&2
nohup "$WRAPPER" >/dev/null 2>&1 &
nohup "$WRAPPER" >/dev/null 2>&1 7>&- &
disown
else
HERDR_SESSION_NAME="$HERDR_SESSION_NAME" _herdr new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" "$CP_PREFIX$CMD_FULL"
@@ -285,6 +285,9 @@ if [ "$DRY_RUN" = "1" ]; then
fi
# Issue #63: a same-cwd session of this post-discovered agent records its id first (lib.sh mam_id_order_wait).
mam_id_order_wait "$WORKSPACE" "$AGENT" "$SESSION_NAME" || exit 1
# Issue #72: no new session while an update runs; held until the row is written
# (lib.sh mam_update_admit).
mam_update_admit || exit 1
HERDR_EPOCH=$(date +%s)
NOW_ISO=$(date -u +'%Y-%m-%dT%H:%M:%SZ')
spawn
@@ -361,7 +364,7 @@ cleanup_herdr_on_error() {
if [ "$alive" = "1" ]; then
_mark_row_rolled_back "running" "rollback-failed"
echo "ERROR: rollback could not kill herdr session '$SESSION_NAME' (kill rc=$kill_rc) - it is still running." >&2
echo " Clean it up with: HERDR_SESSION_NAME=${HERDR_SESSION_NAME:-default} herdr kill-session -t $SESSION_NAME" >&2
echo " Clean it up with: bash .agents/skills/multi-agent-mux-stop/scripts/stop_session.sh --session $SESSION_NAME --herdr-session ${HERDR_SESSION_NAME:-default}" >&2
exit 43
fi
_mark_row_rolled_back "terminated" "rolled-back"
@@ -428,11 +431,6 @@ PANE_PID=$(_herdr list-panes -t "$SESSION_NAME" -F '#{pane_pid}' 2>/dev/null ||
PANE_CWD=$(_herdr list-panes -t "$SESSION_NAME" -F '#{pane_current_path}' 2>/dev/null || echo "$WORKSPACE")
PANE_CMD=$(_herdr list-panes -t "$SESSION_NAME" -F '#{pane_current_command}' 2>/dev/null || echo "$AGENT")
# 시작 명령
# NOTE: this must match what `spawn()` actually ran above — env-var-driven
# herdr server shim (HERDR_SESSION_NAME picked up by the lib.sh shim).
START_CMD="HERDR_SESSION_NAME=${HERDR_SESSION_NAME:-default} herdr new-session -d -s \"$SESSION_NAME\" -x 140 -y 40 -c \"$WORKSPACE\" \"$CMD_FULL\""
# If --onboard is specified, automatically build the onboarding prompt (if session is ready)
if [ "$ONBOARD" = "1" ] && [ -z "$SUBMIT_JOB_PROMPT" ] && [ -z "$LAST_VISIBLE_STATUS_OVERRIDE" ]; then
SUBMIT_JOB_PROMPT="You are a newly spawned $ROLE Team Leader agent in this workspace. Without making any non-standard pre-preparations or modifying files directly, proceed immediately to align yourself with the project context by performing the following tasks:
@@ -508,12 +506,13 @@ fi
atomic_dump_yaml "$AGENT_SESSIONS_YAML" \
SESSION_NAME="$SESSION_NAME" AGENT="$AGENT" NOW_ISO="$NOW_ISO" \
HERDR_EPOCH="$HERDR_EPOCH" PANE_PID="$PANE_PID" PANE_CWD="$PANE_CWD" \
CMD_FULL="$CMD_FULL" START_CMD="$START_CMD" CHILD_PID="$CHILD_PID" \
CMD_FULL="$CMD_FULL" CHILD_PID="$CHILD_PID" \
HERDR_SESSION_NAME="${HERDR_SESSION_NAME:-default}" \
MAM_WS_LABEL="$MAM_WS_LABEL" \
SESSION_UUID="$SESSION_UUID" \
LAST_VISIBLE_STATUS_OVERRIDE="${LAST_VISIBLE_STATUS_OVERRIDE:-}" \
DELEGATE_JOB_ID="$DELEGATE_JOB_ID" ROLE="$ROLE" CONTROL_PLANE_JSON="$CP_JSON" <<'PYEOF'
from lib_py.agents.sanitize import attach_command
name = os.environ['SESSION_NAME']
agent = os.environ['AGENT']
role = os.environ['ROLE']
@@ -567,9 +566,7 @@ entry = {
'cmd_full': os.environ['CMD_FULL'],
'cwd': os.environ['PANE_CWD'],
},
'start_command': os.environ['START_CMD'],
'attach_command': f'HERDR_SESSION_NAME={server_name} herdr agent attach {name}',
'kill_command': f'HERDR_SESSION_NAME={server_name} herdr kill-session -t {name}',
'attach_command': attach_command(server_name, name),
}
if agent == 'claude':
@@ -644,6 +641,7 @@ snap['cwd'] = os.environ['PANE_CWD']
print(f"appended: {name}", flush=True)
PYEOF
ROW_APPENDED=1
mam_update_admit_release
echo
echo "=== created ==="
@@ -773,6 +771,6 @@ fi
trap - EXIT
echo "agent-sessions.yaml updated"
echo
echo "Attach: herdr session attach $SESSION_NAME"
echo "Attach: $(session_attach_command "$SESSION_NAME" "${HERDR_SESSION_NAME:-default}")"
echo "Delete: use multi-agent-mux-stop skill"
echo "Resume: use multi-agent-mux-resume skill (after first message creates a session id)"
@@ -1,7 +1,7 @@
---
name: multi-agent-mux-delegate-job
description: "Delegate a unit of work to any autonomous agent (claude-code, hermes, agy, grok-build, codex, or a human) and observe it asynchronously over an MQTT event channel. Supported roles include orchestrator, worker, and reviewer."
version: 4.10.0
version: 4.11.0
author: godopu
license: MIT
platforms: [linux, macos, windows]
@@ -301,6 +301,26 @@ cmd_submit() {
local reg_dry=()
if [[ "$DRY_RUN" == "1" ]]; then reg_dry=(--dry-run); fi
# Issue #72: no job is registered while an update of this registry's
# workspace or of one the target is in (lib_py.update target-workspaces:
# its row's, or with no row the live agent's on the server delivery uses)
# runs; each is held, fd 7 then 20.., until the record is written (lib.sh
# mam_update_admit). A dry-run record is never dispatched and is not
# counted by the update.
local admit_fds=() target_ws tws fd=20
if [[ "$DRY_RUN" != "1" ]]; then
target_ws="$(MAM_STATE_JSON="$(load_state_json)" PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" "$PY" -m lib_py.update \
target-workspaces --workspace "$WORKSPACE_ROOT" --agent-session "$AGENT_SESSION" \
--server "$(_is_remote "$AGENT_SESSION" || resolve_herdr_session "${AGENT_SESSION#herdr:}" "$WORKDIR" 2>/dev/null)" \
--herdr "$(_resolve_real_herdr_path || true)")" \
|| { echo "ERROR: cannot resolve the workspace of $AGENT_SESSION; nothing was registered" >&2; exit 1; }
mam_update_admit "$WORKSPACE_ROOT" || exit 1
while IFS= read -r tws; do
[[ -n "$tws" ]] || continue
mam_update_admit "$tws" "$fd" || exit 1
admit_fds+=("$fd"); fd=$((fd + 1))
done <<< "$target_ws"
fi
# 1) register job (prints the new job id)
JOB_ID="$("$PY" "$SCRIPT_DIR/scripts/registry.py" --registry-dir "$REGISTRY_DIR" register \
"${reg_prompt[@]}" --agent "$AGENT" --agent-session "$AGENT_SESSION" --role "$DELEGATE_ROLE" \
@@ -310,6 +330,10 @@ cmd_submit() {
--job-type "$TYPE" --reviewer "$REVIEWER" --reviewer-session "$REVIEWER_SESSION" \
--max-iterations "$MAX_ITERATIONS" ${reg_parent[@]+"${reg_parent[@]}"} ${reg_dry[@]+"${reg_dry[@]}"})"
echo "registered job: $JOB_ID"
if [[ "$DRY_RUN" != "1" ]]; then
mam_update_admit_release
for fd in ${admit_fds[@]+"${admit_fds[@]}"}; do mam_update_admit_release "$fd"; done
fi
# 1-1) Provision job directory and write direct brief.md (MAM Job Restructuring)
local job_dir="$REGISTRY_DIR/$JOB_ID"
@@ -895,8 +919,8 @@ finalize_unfinished() {
FINAL_JOB_STATUS="$final"
if [ "$step" = "probe" ]; then
local sess="${target_sess#herdr:}" target_agent diag="no"
target_agent=$(_sanitize_herdr_agent_name "$sess")
_within_deadline "$deadline" _agent_still_working "$sess" "$target_agent" && diag="yes"
target_agent=$(_sanitize_herdr_agent_name "$sess") || target_agent=""
[ -n "$target_agent" ] && _within_deadline "$deadline" _agent_still_working "$sess" "$target_agent" && diag="yes"
echo "liveness: no wire event for ${IDLE_TIMEOUT}s on job $job_id; herdr/pane says busy=$diag (diagnostic only, not evidence)" >&2
fi
# The answer comes back on fd 3: stdout carries registry.py's close output.
@@ -1018,7 +1042,7 @@ inject_agent() {
return 1
fi
echo "작업이 세션 '$sess'에 전송되었습니다. (연결하려면: HERDR_SESSION_NAME=$HERDR_SESSION_NAME herdr agent attach $sess — lib.sh를 source한 셸에서 실행)"
echo "작업이 세션 '$sess'에 전송되었습니다. (연결하려면: $(session_attach_command "$sess" "${HERDR_SESSION_NAME:-default}"))"
trap - EXIT
return 0
}
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: multi-agent-mux-loop
description: "Run an autonomous planning-execution-review loop using multiple agents (Planner, Creator, Reviewers) in the workspace. Automatically orchestrates plan discussion, code changes, and peer reviews until a unanimous PASS is achieved or the maximum iteration limit is reached."
version: 4.10.0
version: 4.11.0
author: godopu
license: MIT
platforms: [linux, macos]
@@ -244,6 +244,9 @@ log_error() {
echo -e "\033[1;31m[✗]\033[0m $1"
}
# Issue #72: no loop starts while an update runs; held until the loop marker
# is published (lib.sh mam_update_admit), released before the loop body runs.
mam_update_admit "$MAM_REAL_ROOT" || exit 1
_mam_acquire_rc=0
mam_acquire_loop_lock "$MAM_LOOP_MARKER" || _mam_acquire_rc=$?
case "$_mam_acquire_rc" in
@@ -262,6 +265,7 @@ case "$_mam_acquire_rc" in
;;
esac
trap _mam_release_guard EXIT INT TERM HUP
mam_update_admit_release
rm -f "$MAM_REAL_ROOT/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job".*.tmp 2>/dev/null || true
# --all-reviewer silently takes precedence over an explicit --reviewer list;
@@ -1,7 +1,7 @@
---
name: multi-agent-mux-monitor
description: "Run a long-lived reconciler that watches .mam/agent-sessions.yaml against the actual herdr/agent runtime state and reconciles them. Use when you want live visibility into which agent sessions are running, which are dead, which have stale YAML entries, and which have new session ids that haven't been recorded yet. Runs as a persistent loop (`reconcile.sh --subscribe`) that keeps going until it times out, idles out, or is interrupted."
version: 4.10.0
version: 4.11.0
author: godopu
license: MIT
platforms: [linux, macos]
@@ -566,7 +566,7 @@ def _pin_and_verify_resume(s, agent, cwd, uuid, degraded=False):
actions.append(f"updated {id_name} id: {uuid}")
from lib_py.agents.sanitize import sanitize_herdr_agent_name as _sanitize
from lib_py.agents.sanitize import sanitize_herdr_agent_name as _sanitize, attach_command
yaml_sessions = d.get('herdr_sessions', [])
yaml_session_names = {s['name'] for s in yaml_sessions if s.get('name')}
@@ -656,7 +656,6 @@ if herdr_confirmed:
_ambient = os.environ.get('HERDR_SESSION_NAME', '')
if srv == 'default' and _ambient and _ambient != 'default':
srv = _ambient
server_opt = f"-L {srv} " if srv != 'default' else ""
# The shim resolves this from the pane's root process. Fall back to now
# only if that failed: 'now' can merely over-estimate creation time,
# which tightens the stale-transcript guard, whereas 0 disables it and
@@ -674,9 +673,7 @@ if herdr_confirmed:
'herdr_server': srv,
'herdr_workspace': _slug(pm['cwd']),
'pane': {'index': 0, 'pid': pm['pid'], 'cmd': agent, 'cmd_full': cmd_full, 'cwd': pm['cwd']},
'start_command': f'HERDR_SESSION_NAME={srv} herdr new-session -d -s "{name}" -x 140 -y 40 -c "{pm["cwd"]}" "{cmd_full}"',
'attach_command': f'HERDR_SESSION_NAME={srv} herdr agent attach {name}',
'kill_command': f'herdr {server_opt}kill-session -t {name}',
'attach_command': attach_command(srv, name),
'last_visible_status': 'running',
'last_visible_note': 'auto-registered by monitor',
}
@@ -1,7 +1,7 @@
---
name: multi-agent-mux-orc-onboard
description: "Register current or specified orchestrator session UUID into agent-sessions.yaml orchestrator_uuids list to prevent sub-agent discovery capture."
version: 4.10.0
version: 4.11.0
author: godopu
license: MIT
platforms: [linux, macos]
@@ -257,6 +257,11 @@ if [ "$DRY_RUN" = true ]; then
exit 0
fi
# Issue #72 (U2): no registry write while an update runs - a rollback would
# restore the registry without it. Held until the write is done (lib.sh
# mam_update_admit).
mam_update_admit || exit 1
# Issue #13: the orchestrator has no herdr_sessions row, so stop_session.sh
# --purge-conversation can never reach its conversation. Purge FIRST, through
# the adapter (`lib_py.agents purge`): a refusal because the conversation is
@@ -304,6 +309,7 @@ elif op == "remove":
uuids = [u for u in uuids if u != target]
d["orchestrator_uuids"] = uuids
PYEOF
mam_update_admit_release
case "$MODE" in
add) verb="added" ;;
@@ -1,7 +1,7 @@
---
name: multi-agent-mux-resume
description: "Resume an existing agent (claude, antigravity/agy) conversation by UUID into a herdr session. Reads .mam/agent-sessions.yaml for the saved session/conversation id, spawns (or reuses) a herdr session of the matching name, and runs `claude -r <id>` or `agy --conversation <id>` inside. Use when you want to reattach to a previous session's context, or revive a session whose herdr died but the agent's conversation is still on disk."
version: 4.10.0
version: 4.11.0
author: godopu
license: MIT
platforms: [linux, macos]
@@ -27,7 +27,7 @@ Three cases this skill handles:
1. **herdr is dead, conversation lives** — `agent-sessions.yaml` has the UUID. The JSONL/db is on disk. Re-spawn the herdr session + run `claude -r <id>` / `agy --conversation <id>`.
2. **herdr is alive but empty** — You started a session with `multi-agent-mux-create` but haven't sent a message yet (so no session id was assigned). The user can either send their first message (and the id is auto-assigned), or you can read the *workspace's* most recent conversation from `$HOME_DIR/.gemini/antigravity-cli/cache/last_conversations.json` (defaults to `~/.gemini/...`) for agy, or the latest `*.jsonl` in `$CLAUDE_PROJECT_DIR/<workspace-key>/` (defaults to `~/.claude/projects/`) for claude.
3. **herdr is alive AND the agent inside is already running** — `resume_session.sh` only updates the row and exits 0. It does not attach and does not check the agent's permission flags (#45); attach with `herdr --session <server> agent attach <name>`, where `<server>` is the row's `herdr_session` (the server it was resumed on).
3. **herdr is alive AND the agent inside is already running** — `resume_session.sh` only updates the row and exits 0. It does not attach and does not check the agent's permission flags (#45); attach with the row's `attach_command` (`herdr --session <server> agent attach <name>`, where `<server>` is the row's `herdr_session` and `<name>` the name herdr registered).
### Resuming a `stopped` session (`stopped → running`)
@@ -60,7 +60,7 @@ If the resolved id is empty → for Class A agents (`agy`, `hermes`, `opencode`,
bash .agents/skills/multi-agent-mux-resume/scripts/resume_session.sh --workspace <path> --agent <agent> --session <name>
```
`resume_session.sh` does the whole flow: original-cwd check, control-plane binding, the session-id decision (`lib_py/agents/session_ids.py`), the herdr-alive check, the agent command (adapter `resume-spec`, or `spawn-spec` for a Class A fresh spawn), spawn (just before it, the session's own empty shell left by a server restart is closed: same session id, no agent, nothing running; `lib_py/herdr_restore.py`), start-up dialogs, and the YAML update (`update_yaml_resumed.sh`, which also publishes a `progress "resumed"` event when the row has a `delegate_job_id`). It never attaches; attaching is a separate step that names the server, because the server the script exports does not reach the calling shell and the native `herdr` binary does not route on environment variables: `herdr --session <server> agent attach <name>` (`<server>` = the `--herdr-session` you passed, or the row's `herdr_session`).
`resume_session.sh` does the whole flow: original-cwd check, control-plane binding, the session-id decision (`lib_py/agents/session_ids.py`), the herdr-alive check, the agent command (adapter `resume-spec`, or `spawn-spec` for a Class A fresh spawn), spawn (just before it, the session's own empty shell left by a server restart is closed: same session id, no agent, nothing running; `lib_py/herdr_restore.py`), start-up dialogs, and the YAML update (`update_yaml_resumed.sh`, which also publishes a `progress "resumed"` event when the row has a `delegate_job_id`), then the control root's monitor daemon (`start_watchdog`, as create; not with `MAM_NO_SUBSCRIBER_DAEMON=1`). It never attaches; attaching is a separate step that names the server, because the server the script exports does not reach the calling shell and the native `herdr` binary does not route on environment variables: the row's `attach_command`, `herdr --session <server> agent attach <name>` (`<server>` = the `--herdr-session` you passed, or the row's `herdr_session`; `<name>` = the name herdr registered).
| Option | Meaning |
|---|---|
@@ -75,11 +75,11 @@ bash .agents/skills/multi-agent-mux-resume/scripts/resume_session.sh --workspace
| Exit | Meaning |
|---|---|
| 0 | Resumed; or already running, so only the row was updated (no attach); or `--dry-run` |
| 1 | From the checks before spawn, nothing was changed: no saved session for claude/grok (use `multi-agent-mux-create` first), the row's id is also claimed by another row, a same-cwd Class A session did not record its id within `MAM_ID_ORDER_WAIT_S` (default 600), or agent binary missing/not executable. **The YAML update after spawn (`update_yaml_resumed.sh`) also exits 1** (YAML not found) |
| 1 | From the checks before spawn, nothing was changed: no saved session for claude/grok (use `multi-agent-mux-create` first), the row's id is also claimed by another row, a same-cwd Class A session did not record its id within `MAM_ID_ORDER_WAIT_S` (default 600), agent binary missing/not executable, or an update of the workspace is in progress (`multi-agent-mux-update`). **The YAML update after spawn (`update_yaml_resumed.sh`) also exits 1** (YAML not found) |
| 2 | From the checks before spawn, nothing was changed: bad arguments, `--workspace` differs from the original cwd or it no longer exists, control-plane binding refused, whitespace in the control-plane path. **The YAML update after spawn also exits 2** (bad arguments) |
| other | A later step failed (herdr spawn, start-up dialogs, YAML update) and its code is passed through. resume does not roll back |
A non-zero exit after the spawn can leave the herdr session running while the row is still `stopped`. Before retrying or creating a new session, check with `multi-agent-mux-status`. The `ERROR:` line on stderr tells the two apart: the pre-spawn checks' messages are in `resume_session.sh` (lines 50-243).
A non-zero exit after the spawn can leave the herdr session running while the row is still `stopped`. Before retrying or creating a new session, check with `multi-agent-mux-status`. The `ERROR:` line on stderr tells the two apart: the pre-spawn checks' messages are in `resume_session.sh` (lines 50-259).
## Original cwd only (Issue #21 phase 2)
@@ -136,4 +136,4 @@ herdr --session "$SERVER" agent read "$SESSION_NAME" --source visible --lines 30
- **No saved session yet** → `multi-agent-mux-create`
- **Killing an existing session** → `multi-agent-mux-stop`
- **Just attaching** → `herdr --session <server> agent attach <name>` (no skill needed; `<server>` = the row's `herdr_session`)
- **Just attaching** → the row's `attach_command` (no skill needed; `herdr --session <server> agent attach <name>`, `<name>` = the name herdr registered)
@@ -154,18 +154,34 @@ if [ -z "$UUID" ]; then
esac
fi
# Issue #72: a resumed session gets the monitor daemon create starts - the
# control root's (Issue #21 A8), also for a worktree --workspace. Called after
# the row is written; MAM_NO_SUBSCRIBER_DAEMON=1 starts none. Never fails the resume.
_ensure_monitor_daemon() {
local wd_pid
if wd_pid=$(start_watchdog "resume" "$WORKSPACE_ROOT"); then
echo "watchdog PID: $wd_pid"
else
echo "WARNING: could not start the monitor daemon of '$WORKSPACE_ROOT'; the session itself was resumed" >&2
fi
}
# 2. If herdr is alive, print warning or attach.
if [ "$HERDR_ALIVE" = "1" ]; then
if [ "${DRY_RUN:-0}" = "1" ]; then
echo "[dry-run] herdr '$SESSION_NAME' already running — nothing to validate"
exit 0
fi
# Issue #72: no registry write while an update runs (lib.sh mam_update_admit).
mam_update_admit || exit 1
echo "herdr '$SESSION_NAME' already running."
# Just update YAML to make sure it's set to running
CONTROL_PLANE_JSON="$CP_JSON" bash "$(dirname "${BASH_SOURCE[0]}")/update_yaml_resumed.sh" \
--session "$SESSION_NAME" --uuid "$UUID" --agent "$AGENT" --workspace "$WORKSPACE" \
--herdr-session "$HERDR_SESSION_NAME" --already-running \
${HERDR_WORKSPACE_OPT:+--herdr-workspace "$HERDR_WORKSPACE_OPT"}
mam_update_admit_release
_ensure_monitor_daemon
exit 0
fi
@@ -238,6 +254,10 @@ if [ "${DRY_RUN:-0}" = "1" ]; then
exit 0
fi
# Issue #72: no new session while an update runs; held until the row is written
# (lib.sh mam_update_admit).
mam_update_admit || exit 1
# Issue #63: wait if another same-cwd session of this post-discovered agent
# is still being created or has not recorded its id yet (lib.sh mam_id_order_wait).
mam_id_order_wait "$WORKSPACE" "$AGENT" "$SESSION_NAME" "$UUID" || exit 1
@@ -274,5 +294,7 @@ CONTROL_PLANE_JSON="$CP_JSON" bash "$(dirname "${BASH_SOURCE[0]}")/update_yaml_r
--herdr-session "$HERDR_SESSION_NAME" --cmd-full "$CMD_FULL" \
${DROP_FLAG:+$DROP_FLAG} \
${HERDR_WORKSPACE_OPT:+--herdr-workspace "$HERDR_WORKSPACE_OPT"}
mam_update_admit_release
_ensure_monitor_daemon
echo "Successfully resumed $SESSION_NAME ($AGENT)"
@@ -116,6 +116,7 @@ atomic_dump_yaml "$AGENT_SESSIONS_YAML" \
MAM_WS_LABEL="$MAM_WS_LABEL" MAM_WS_LABEL_EXPLICIT="${MAM_WS_LABEL_EXPLICIT:-0}" \
ALREADY_RUNNING="$ALREADY_RUNNING" DROP_STALE_ID="$DROP_STALE_ID" \
CMD_FULL_OPT="$CMD_FULL_OPT" <<'PYEOF'
from lib_py.agents.sanitize import attach_command
name = os.environ['SESSION_NAME']
uuid = os.environ['UUID']
agent = os.environ['AGENT']
@@ -147,9 +148,7 @@ if target is None:
'herdr_workspace': wsl,
'delegate_job_id': None,
'pane': {'index': 0, 'pid': int(pane_pid) if pane_pid.isdigit() else 0, 'cmd': agent, 'cwd': ws_root},
'start_command': f'HERDR_SESSION_NAME={server_name} herdr agent attach {name}',
'attach_command': f'HERDR_SESSION_NAME={server_name} herdr agent attach {name}',
'kill_command': f'HERDR_SESSION_NAME={server_name} herdr kill-session -t {name}',
'attach_command': attach_command(server_name, name),
}
d.setdefault('herdr_sessions', []).append(target)
else:
@@ -159,9 +158,7 @@ else:
if is_explicit or not target.get('herdr_session'):
target['herdr_session'] = sn
target['herdr_server'] = sn
target['start_command'] = f'HERDR_SESSION_NAME={sn} herdr agent attach {name}'
target['attach_command'] = f'HERDR_SESSION_NAME={sn} herdr agent attach {name}'
target['kill_command'] = f'HERDR_SESSION_NAME={sn} herdr kill-session -t {name}'
target['attach_command'] = attach_command(sn, name)
wsl = os.environ.get('MAM_WS_LABEL', '')
ws_explicit = os.environ.get('MAM_WS_LABEL_EXPLICIT') == '1'
if wsl and (ws_explicit or not target.get('herdr_workspace')):
@@ -1,7 +1,7 @@
---
name: multi-agent-mux-status
description: "Read-only instant snapshot of all agent herdr sessions — name, YAML status, herdr alive, pane cmd/cwd, resume UUID on disk, and any drift. No mutation. Reuses reconcile.sh --dry-run for the diff logic. Use when you want to know 'what's running RIGHT NOW' without spinning up the monitor loop."
version: 4.10.0
version: 4.11.0
author: godopu
license: MIT
platforms: [linux, macos]
@@ -94,11 +94,10 @@ lab-paper-pdf2md-creator-claude default running alive clau
"job_id": null,
"job_status": null,
"pane_cwd": "/home/.../refer_landing_page",
"attach_command": "HERDR_SESSION_NAME=default herdr agent attach lab-landing-page-creator-claude",
"attach_command": "herdr --session default agent attach lab-landing-page-creator-claude",
"drift_classes": [],
"pane_pid": 12345,
"cmd_full": "claude --dangerously-skip-permissions --session-id <uuid>",
"start_command": "...",
"last_visible_status": "pinned"
}
]
@@ -221,11 +221,10 @@ for s in d.get('herdr_sessions', []):
'pane_cwd': pane.get('cwd'),
'attach_command': s.get('attach_command'),
'drift_classes': drift_by_name.get(name, []),
# Additive beyond the D8 example — needed by the M1 Detail Pane
# (pane pid / full launch command / start command / last banner text).
# Additive beyond the D8 example (pane pid / full launch command /
# last banner text).
'pane_pid': pane.get('pid'),
'cmd_full': pane.get('cmd_full'),
'start_command': s.get('start_command'),
'last_visible_status': s.get('last_visible_status'),
})
+5 -4
View File
@@ -1,7 +1,7 @@
---
name: multi-agent-mux-stop
description: "Stop an agent herdr session (claude, antigravity/agy) and update .mam/agent-sessions.yaml. Default stops gracefully and marks status=stopped with conversation preserved for resume. Does NOT delete on-disk conversation artifacts (jsonl/db) — those are preserved unless --purge-conversation is passed. Use when ending a work session, switching to a different one, or cleaning up before a fresh start."
version: 4.10.0
version: 4.11.0
author: godopu
license: MIT
platforms: [linux, macos]
@@ -25,7 +25,6 @@ Stop an agent's herdr session gracefully, resolve and store the conversation ID,
- The herdr session's recorded `pane.pid / cmd / cwd / mcp_attachments` for audit
- The agent's on-disk conversation (claude `*.jsonl`, agy `conversations/*.db`) — so the user can `multi-agent-mux-resume` later
- The `start_command` so a future `multi-agent-mux-create --session <name>` reproduces the same herdr spec
The stop command is always **graceful by default**:
1. Sends exit keys to the agent TUI (`/exit` for Claude, `/exit` for Agy) and waits 3 seconds.
@@ -35,7 +34,7 @@ The stop command is always **graceful by default**:
## Pre-flight
`stop_session.sh` checks this itself: the YAML exists and the session is registered (otherwise exit 1), and a session already `stopped` is a no-op (exit 0).
`stop_session.sh` checks this itself: the YAML exists (otherwise exit 1), and a session already `stopped` is a no-op (exit 0). A session with no registry row (its create or resume died before registering it) is still closed, herdr only and with no `--agent`: only when herdr runs it inside this workspace, on the `--herdr-session` server (or `HERDR_SESSION_NAME`). The registry is not changed.
## Workflow
@@ -59,10 +58,12 @@ bash .agents/skills/multi-agent-mux-stop/scripts/stop_session.sh \
| Exit | Meaning |
|---|---|
| 0 | Stopped; or already `stopped` (no-op, except with `--purge-conversation`, which still purges) |
| 1 | From the checks before the stop, nothing was changed: YAML not found, or the session is not registered. **The registry update after the stop also exits 1** (the row disappeared from the YAML during the run) |
| 1 | From the checks before the stop, nothing was changed: YAML not found, its row could not be read, the session is neither registered nor on the herdr server, or an update of the workspace is in progress (`multi-agent-mux-update`). **The registry update after the stop also exits 1** (the row disappeared from the YAML during the run) |
| 2 | Bad arguments (including the retired `--mode` / `--capture-id` / `--graceful`), or `--agent` omitted and not resolvable |
| 3 | `--purge-conversation` without `--yes` |
| 4 | Purge aborted: the herdr session, or an agent process still holding the conversation, survived the kill chain. Records preserved: the registry row and the conversation files are untouched, though the session itself may already be down |
| 5 | No registry row, and herdr runs the session outside this workspace: refused, nothing changed |
| 6 | No registry row, and its herdr session survived `kill-session`; the registry is unchanged |
| other | The registry update after the stop (YAML / DB write) failed and its code is passed through |
Exits 2 and 3, and exit 1 from the pre-stop checks, come before anything is stopped, so nothing was changed. On exit 1 from the registry update, or an `other` exit, the session may already be down while its row still says `running`: check with `multi-agent-mux-status`, then run the stop again (it is idempotent).
@@ -28,9 +28,12 @@
# 구분은 --purge-conversation 유무로 대체되었습니다.
#
# Exit codes:
# 0 = success (or already-stopped no-op) | 1 = YAML not found / not registered
# 0 = success (or already-stopped no-op; or a session with no row closed)
# 1 = YAML not found / its row unreadable / not registered and not on the herdr server
# 2 = invalid args | 3 = interactive confirmation required (--yes 누락)
# 4 = purge aborted (herdr session or an agent process holding the conversation survived the kill chain)
# 5 = no row, and herdr runs the session outside this workspace (refused, nothing changed)
# 6 = no row, and its herdr session survived kill-session (registry unchanged)
set -euo pipefail
# shellcheck disable=SC1091
@@ -97,12 +100,18 @@ fi
[ -n "$SESSION_NAME" ] || { echo "ERROR: --session required" >&2; usage; exit 2; }
[ -f "$AGENT_SESSIONS_YAML" ] || { echo "ERROR: $AGENT_SESSIONS_YAML not found" >&2; exit 1; }
# Issue #72 (U2): no registry write while an update runs - a rollback would
# restore the registry without it. Held until the row is written (lib.sh
# mam_update_admit).
mam_update_admit || exit 1
# Implement the purging-<session> file lock mechanism
if [ "$PURGE" = "1" ]; then
touch "$WORKSPACE_ROOT/.mam/purging-$SESSION_NAME"
trap 'rm -f "$WORKSPACE_ROOT/.mam/purging-$SESSION_NAME"' EXIT
fi
AMBIENT_HERDR_SESSION="${HERDR_SESSION_NAME:-}"
if [ -n "$HERDR_SERVER_OPT" ]; then
export HERDR_SESSION_NAME="$HERDR_SERVER_OPT"
else
@@ -110,20 +119,64 @@ else
export HERDR_SESSION_NAME
fi
# --agent 미지정 시 레지스트리 기록으로 해석 (B-21).
# ① row['agent'] → ② 세션명 접미사 → ③ pane.cmd 순. 셋 다 실패하면
# 종전과 동일하게 exit 2 (헤더 :27-30 의 종료 코드 계약 유지).
if [ -z "$AGENT" ]; then
AGENT="$(resolve_agent_type_from_registry "$SESSION_NAME")" || AGENT=""
[ -n "$AGENT" ] || {
echo "ERROR: cannot infer agent from '$SESSION_NAME'; pass --agent" >&2
exit 2
}
fi
# Issue #71: a session with no registry row (its create/resume died before
# registering it) is closed here - herdr only, the registry is left alone. It
# has no agent kind, so --agent is not needed. Only a session herdr runs
# inside this workspace is closed; anything else changes nothing. With no row
# the server is chosen as create_session.sh chose it: --herdr-session, else
# the environment's HERDR_SESSION_NAME, else the workspace's server.
stop_rowless() {
if [ "$PURGE" = "1" ]; then
echo "ERROR: session '$SESSION_NAME' not in $AGENT_SESSIONS_YAML; --purge-conversation needs its row. Nothing changed." >&2
exit 1
fi
if [ -z "$HERDR_SERVER_OPT" ] && [ -n "$AMBIENT_HERDR_SESSION" ] && [ "$AMBIENT_HERDR_SESSION" != "default" ]; then
export HERDR_SESSION_NAME="$AMBIENT_HERDR_SESSION"
fi
local info cwd
info=$(herdr agent get "$SESSION_NAME" 2>/dev/null) || info=""
if [ -z "$info" ]; then
echo "ERROR: session '$SESSION_NAME' not in $AGENT_SESSIONS_YAML, and herdr server '${HERDR_SESSION_NAME:-default}' has no agent of that name. Nothing changed." >&2
exit 1
fi
cwd=$(printf '%s' "$info" | python3 -c '
import sys, json
raw = sys.stdin.read()
try:
print(json.loads(raw[raw.index("{"):]).get("result", {}).get("agent", {}).get("cwd", "") or "")
except Exception:
print("")
')
if ! PANE_CWD="$cwd" python3 -c '
import os, sys
cwd, root = os.environ.get("PANE_CWD", ""), os.environ.get("WORKSPACE_ROOT", "")
if not cwd or not root:
sys.exit(1)
cwd, root = os.path.realpath(cwd), os.path.realpath(root)
sys.exit(0 if cwd == root or cwd.startswith(root + os.sep) else 1)
'; then
echo "ERROR: refusing to stop '$SESSION_NAME': it is not in $AGENT_SESSIONS_YAML, and herdr runs it in '${cwd:-<unknown>}', outside this workspace ($WORKSPACE_ROOT). Nothing changed." >&2
exit 5
fi
echo "session '$SESSION_NAME' has no registry row: closing its herdr session only (registry unchanged)"
herdr kill-session -t "$SESSION_NAME" 2>/dev/null || true
_wait_session_gone "$SESSION_NAME" 8 || true
if herdr has-session -t "$SESSION_NAME" 2>/dev/null; then
echo "ERROR: herdr session '$SESSION_NAME' is still running after kill-session (registry unchanged)" >&2
exit 6
fi
echo "closed herdr session '$SESSION_NAME' on server '${HERDR_SESSION_NAME:-default}' (no registry row)"
exit 0
}
# 세션이 YAML 에 있는지 + 해당 row 의 워크스페이스 cwd 및 delegate_job_id 추출.
# JSON 으로 emit — cwd 에 '|' 가 들어가도 안전 (review item 7; 기존 cwd|jid 파서 대체).
MAPPED_DATA=$(MAM_STATE_JSON="$(load_state_json)" SESSION_NAME="$SESSION_NAME" python3 -c "
# A registry that cannot be read is not "no row" (Issue #71): stop before herdr.
STATE_JSON=$(load_state_json strict) || {
echo "ERROR: cannot read $AGENT_SESSIONS_YAML, so whether '$SESSION_NAME' has a row is unknown. Nothing changed." >&2
exit 1
}
MAPPED_DATA=$(MAM_STATE_JSON="$STATE_JSON" SESSION_NAME="$SESSION_NAME" python3 -c "
import sys, os, json
name = os.environ['SESSION_NAME']
d = json.loads(os.environ.get('MAM_STATE_JSON', '{}'))
@@ -136,10 +189,24 @@ for s in d.get('herdr_sessions', []):
'delegate_job_logs_dir': cp.get('delegate_job_logs_dir', '') or ''}))
sys.exit(0)
sys.exit(7)
") || {
echo "ERROR: session '$SESSION_NAME' not in $AGENT_SESSIONS_YAML" >&2
") || MAPPED_RC=$?
# 7 = no row; any other failure (registry unreadable) stops as before.
[ "${MAPPED_RC:-0}" -ne 7 ] || stop_rowless
if [ "${MAPPED_RC:-0}" -ne 0 ]; then
echo "ERROR: cannot read the row of '$SESSION_NAME' from $AGENT_SESSIONS_YAML" >&2
exit 1
}
fi
# --agent 미지정 시 레지스트리 기록으로 해석 (B-21).
# ① row['agent'] → ② 세션명 접미사 → ③ pane.cmd 순. 셋 다 실패하면
# 종전과 동일하게 exit 2 (헤더 :27-30 의 종료 코드 계약 유지).
if [ -z "$AGENT" ]; then
AGENT="$(resolve_agent_type_from_registry "$SESSION_NAME")" || AGENT=""
[ -n "$AGENT" ] || {
echo "ERROR: cannot infer agent from '$SESSION_NAME'; pass --agent" >&2
exit 2
}
fi
TARGET_CWD=$(printf '%s' "$MAPPED_DATA" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("cwd",""))')
DELEGATE_JOB_ID=$(printf '%s' "$MAPPED_DATA" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("job_id",""))')
@@ -270,7 +337,7 @@ if [ "$PURGE" = "1" ] && [ "$HERDR_ALIVE" = "1" ]; then
if herdr has-session -t "$SESSION_NAME" 2>/dev/null; then
echo "ERROR: session '$SESSION_NAME' is still alive after the kill chain." >&2
echo " Refusing registry removal — records preserved (no state was modified)." >&2
echo " Diagnose the stuck TUI (herdr session attach '$SESSION_NAME'), then re-run" >&2
echo " Diagnose the stuck TUI ($(session_attach_command "$SESSION_NAME" "${HERDR_SESSION_NAME:-default}")), then re-run" >&2
echo " stop_session.sh --purge-conversation --yes (retry is safe/idempotent)." >&2
delegate_publish_event "$DELEGATE_JOB_ID" cancelled "purge aborted: session still alive; no state was modified" "$DELEGATE_JOB_TOKEN" "$DELEGATE_JOB_ATTEMPT"
exit 4
@@ -391,6 +458,7 @@ if purge:
else:
print(f"updated: {name} status={target['status']}", flush=True)
PYEOF
mam_update_admit_release
delegate_publish_event "$DELEGATE_JOB_ID" cancelled "session stopped by operator before job completion" "$DELEGATE_JOB_TOKEN" "$DELEGATE_JOB_ATTEMPT"
@@ -0,0 +1,30 @@
---
name: multi-agent-mux-update
description: "Update an installed MAM workspace in place to the latest version. Refuses while a session, an open job, a review loop or an unknown monitor is live; stops the monitor daemon and starts it again afterwards; rolls everything back if the install fails; converts the registry with the newly installed code. Use instead of the removed .mam_deploy/update.sh."
version: 4.11.0
author: godopu
license: MIT
platforms: [linux, macos]
environments: [terminal, herdr]
metadata:
hermes:
tags: [update, install, registry, migration]
related_skills: [multi-agent-mux-stop, multi-agent-mux-resume, multi-agent-mux-monitor]
---
# Multi-Agent Update
```bash
bash .agents/skills/multi-agent-mux-update/scripts/update.sh --yes
```
| Item | Behaviour |
|---|---|
| Where | An installed workspace only (`.mam_deploy/remove.sh` present). A git checkout updates with `git pull`. |
| Confirmation | `[y/N]` prompt; without a terminal `-y`/`--yes` is required. There is no force option. |
| Refused when | a running registry row, a live herdr agent whose cwd is in the workspace (any running server), an open job (an unreadable job JSON or jobs directory included, `dry_run` excluded), a live loop marker, `.mam/monitor.lock` held by a monitor the skills did not start, or another update. The calling agent (its herdr server + pane, row and one running job) is not counted. Each refusal lists the cleanup; nothing is changed. |
| During the update | create, resume, `run_loop.sh`, delegate submit, stop and orc-onboard refuse (`.mam/update-in-progress`, `.mam/update-admission.lock`). |
| Monitor | stopped first, started again afterwards only if it ran (`MAM_NO_SUBSCRIBER_DAEMON=1`: not started). |
| Install | the installer at `MAM_INSTALLER_URL` (default: Gitea `main`) runs in place; `.mam` is kept. On failure `.agents/`, `.mam_deploy/`, root files, `.venv`, the installer's `.mam` files and the registry are restored from `.mam/update-backup/`. |
| Registry | converted by the new code (`lib_py.registry_format`); a failed conversion keeps the install and exits nonzero. |
| Exit | `0` updated (or cancelled at the prompt), `1` refused or failed, `2` bad arguments; a failed registry conversion after a good install exits with the converter's nonzero code. |
@@ -0,0 +1,129 @@
#!/usr/bin/env bash
# install_phase.sh — multi-agent-mux-update, the part that changes the tree (Issue #72, decision 5).
#
# install_phase.sh run <workspace> <monitor_was_running 0|1> <tmpdir>
# install_phase.sh restore <workspace> [--skip-registry]
#
# update.sh copies this file outside the workspace and execs the copy (same
# pid and start time, so its .mam/update-in-progress marker stays live). From
# here on the tree is being replaced, so this calls no lib.sh or lib_py: it runs
# the installer in place, and on failure restores .mam/update-backup/ and starts
# the monitor again with the restored (old, whole) tree's start_watchdog. On
# success phase 2 is the NEW tree's post_update.sh, in a child process. It ends
# by removing the backup and the marker (only its own: pid + lstart).
# Must run on macOS bash 3.2.
set -uo pipefail
MODE="${1:-}"
WS="${2:-}"
[ -n "$MODE" ] && [ -d "$WS" ] || { echo "usage: $0 run|restore <workspace> ..." >&2; exit 2; }
BK="$WS/.mam/update-backup"
set_state() { printf '%s\n' "$1" > "$BK/state.tmp" && mv -f "$BK/state.tmp" "$BK/state"; }
# Puts back every backed-up item, removes the ones that did not exist before,
# then (unless --skip-registry) the registry: the DB from the backup, its
# -wal/-shm removed (macOS keeps them after close).
restore() {
local rel n
[ -f "$BK/items" ] || { echo "ERROR: $BK/items is missing" >&2; return 1; }
while IFS= read -r rel; do
[ -n "$rel" ] || continue
rm -rf "${WS:?}/$rel" || return 1
grep -Fqx -- "$rel" "$BK/absent" 2>/dev/null && continue
mkdir -p "$(dirname "$WS/$rel")" && cp -pPR "$BK/files/$rel" "$WS/$rel" || return 1
done < "$BK/items"
[ "${1:-}" = "--skip-registry" ] && return 0
for n in agent-sessions.yaml agent-sessions.yaml.bak agent-sessions.db; do
rm -f "$WS/.mam/$n" || return 1
if [ -f "$BK/registry/$n" ]; then
cp -p "$BK/registry/$n" "$WS/.mam/$n" || return 1
fi
done
rm -f "$WS/.mam/agent-sessions.db-wal" "$WS/.mam/agent-sessions.db-shm"
}
release_marker() {
local m="$WS/.mam/update-in-progress" pid lstart cur
[ -f "$m" ] || return 0
pid="$(grep '^pid=' "$m" | head -n 1 | cut -d= -f2-)"
lstart="$(grep '^lstart=' "$m" | head -n 1 | cut -d= -f2-)"
cur="$(ps -p $$ -o lstart= 2>/dev/null | tr -s ' ' | sed 's/^ *//;s/ *$//')"
if [ "$pid" = "$$" ] && [ "$lstart" = "$cur" ]; then
rm -f "$m"
fi
}
restart_old_monitor() {
[ "$1" = "1" ] || return 0
if [ "${MAM_NO_SUBSCRIBER_DAEMON:-0}" = "1" ]; then
echo "monitor: not restarted (MAM_NO_SUBSCRIBER_DAEMON=1)"
return 0
fi
local pid
if pid="$(bash -c 'source "$1/.agents/skills/lib.sh" && start_watchdog update "$1"' _ "$WS")"; then
echo "monitor: restarted (PID $pid)"
else
echo "ERROR: could not restart the monitor daemon" >&2
fi
}
case "$MODE" in
restore)
restore "${3:-}"
exit $?
;;
run) ;;
*) echo "usage: $0 run|restore <workspace> ..." >&2; exit 2 ;;
esac
MON="${3:-0}"
TMPD="${4:-}"
trap '[ -z "$TMPD" ] || rm -rf "$TMPD"' EXIT
if ! set_state installing; then
# Nothing was installed yet: drop the backup and put the monitor back as phase 1 found it.
echo "ERROR: cannot write $BK/state; nothing was changed" >&2
rm -rf "$BK"
restart_old_monitor "$MON"
release_marker
exit 1
fi
URL="${MAM_INSTALLER_URL:-https://git.godopu.com/tmpl/multi-agent-mux/raw/branch/main/deploy/install.sh}"
echo "install: $URL"
irc=0
if command -v curl >/dev/null 2>&1; then
curl -fsSL "$URL" | bash -s -- "$WS" || irc=$?
elif command -v wget >/dev/null 2>&1; then
wget -qO- "$URL" | bash -s -- "$WS" || irc=$?
else
echo "ERROR: neither curl nor wget is available to fetch the installer" >&2
irc=1
fi
if [ "$irc" -ne 0 ]; then
echo "ERROR: the install failed (rc $irc); rolling back" >&2
if restore; then
rm -rf "$BK"
echo "rolled back: the workspace is at the version it had before the update"
restart_old_monitor "$MON"
else
echo "ERROR: the rollback failed; the backup is kept at $BK (the next update restores it)" >&2
fi
release_marker
exit 1
fi
set_state installed
POST="$WS/.agents/skills/multi-agent-mux-update/scripts/post_update.sh"
prc=0
if [ -f "$POST" ]; then
bash "$POST" --monitor "$MON" || prc=$?
else
echo "ERROR: the installed version has no $POST; the registry converts on its next write and the monitor was not restarted" >&2
prc=1
fi
rm -rf "$BK"
release_marker
[ "$prc" -eq 0 ] && echo "Update complete."
exit "$prc"
@@ -0,0 +1,47 @@
#!/usr/bin/env bash
# post_update.sh — multi-agent-mux-update phase 2 (Issue #72, decision 5).
# post_update.sh --monitor <0|1>
#
# Run by install_phase.sh from the NEWLY installed tree, in its own process:
# converts the registry with the new code's entry point (lib_py.registry_format,
# #71) and starts the monitor again if it ran before the update. Exit 0, or
# nonzero when the conversion or the monitor restart failed (the install is
# kept either way: the registry converts again on its next write).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WS="$(cd -P "$SCRIPT_DIR/../../../.." && pwd -P)"
export WORKSPACE_ROOT="$WS"
export AGENT_SESSIONS_YAML="$WS/.mam/agent-sessions.yaml"
# shellcheck disable=SC1091
source "$WS/.agents/skills/lib.sh"
MON=0
while [ $# -gt 0 ]; do
case "$1" in
--monitor) MON="$2"; shift 2 ;;
*) echo "ERROR: unknown arg: $1" >&2; exit 2 ;;
esac
done
rc=0
if [ ! -f "$AGENT_SESSIONS_YAML" ] && [ ! -f "$WS/.mam/agent-sessions.db" ]; then
echo "registry: nothing to convert (no registry)"
else
"$(_delegate_py_bin)" -m lib_py.registry_format --yaml "$AGENT_SESSIONS_YAML" || rc=$?
if [ "$rc" -ne 0 ]; then
echo "ERROR: the registry was not converted (rc $rc); the install is kept and the next registry write converts it" >&2
fi
fi
if [ "$MON" = "1" ]; then
if [ "${MAM_NO_SUBSCRIBER_DAEMON:-0}" = "1" ]; then
echo "monitor: not restarted (MAM_NO_SUBSCRIBER_DAEMON=1)"
elif pid="$(start_watchdog update "$WS")"; then
echo "monitor: restarted (PID $pid)"
else
echo "ERROR: could not restart the monitor daemon" >&2
rc=1
fi
fi
exit "$rc"
+257
View File
@@ -0,0 +1,257 @@
#!/usr/bin/env bash
# update.sh — multi-agent-mux-update (Issue #72): update an installed workspace in place.
# Usage:
# bash .agents/skills/multi-agent-mux-update/scripts/update.sh [-y|--yes]
#
# Phase 1 (this script, the installed code): publish .mam/update-in-progress
# under the admission lock (lib.sh mam_update_admit is the other side), refuse
# while anything live could see the tree change (lib_py/update.py precheck, a
# live loop marker, a monitor.lock held by a monitor the skills did not start),
# stop the monitor daemon, back up what the install can change into
# .mam/update-backup/, then exec a copy of install_phase.sh outside the tree.
# That copy runs the installer (MAM_INSTALLER_URL) in place, rolls back on
# failure, and runs phase 2 (post_update.sh of the NEW tree) on success.
#
# Exit codes:
# 0 = updated (registry converted or nothing to convert)
# 1 = refused or failed; what was changed and restored is printed
# 2 = invalid args
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WS="$(cd -P "$SCRIPT_DIR/../../../.." && pwd -P)"
export WORKSPACE_ROOT="$WS"
export AGENT_SESSIONS_YAML="$WS/.mam/agent-sessions.yaml"
# shellcheck disable=SC1091
source "$WS/.agents/skills/lib.sh"
# shellcheck disable=SC1091
source "$SKILL_DIR/multi-agent-mux-loop/scripts/loop_lock.sh"
YES=0
while [ $# -gt 0 ]; do
case "$1" in
-y|--yes) YES=1; shift ;;
-h|--help) sed -n '2,4p' "${BASH_SOURCE[0]}"; exit 0 ;;
*) echo "ERROR: unknown arg: $1" >&2; exit 2 ;;
esac
done
MAM="$WS/.mam"
MARKER="$MAM/update-in-progress"
BK="$MAM/update-backup"
PY="$(_delegate_py_bin)"
if [ ! -f "$WS/.mam_deploy/remove.sh" ] && [ ! -f "$WS/remove.sh" ]; then
echo "ERROR: $WS is not an installed MAM workspace (no .mam_deploy/remove.sh); a git checkout updates with git pull" >&2
exit 1
fi
if [ "$YES" != "1" ]; then
if [ ! -t 0 ]; then
echo "ERROR: not a terminal; run with -y/--yes" >&2
exit 1
fi
ans=""
read -r -p "Update MAM in $WS to the latest version? [y/N]: " ans || ans=""
case "$ans" in
y|Y|yes|Yes|YES) ;;
*) echo "Update cancelled."; exit 0 ;;
esac
fi
# --- the marker, published under the exclusive admission lock (decision 8) ---
mkdir -p "$MAM"
exec 7>>"$MAM/update-admission.lock"
case "$("$PY" -m lib_py.pane_lock acquire --fd 7 --wait-env MAM_UPDATE_ADMISSION_WAIT_S)" in
locked) ;;
timeout)
echo "ERROR: a create, resume, loop, delegate submit, stop or orc-onboard kept the admission lock past MAM_UPDATE_ADMISSION_WAIT_S; nothing was changed, try again" >&2
exit 1 ;;
*)
echo "ERROR: the admission lock $MAM/update-admission.lock could not be decided (lib_py.pane_lock); nothing was changed" >&2
exit 1 ;;
esac
if [ -f "$MARKER" ] && [ "$(_mam_lock_holder_state "$MARKER")" = "stale" ]; then
echo "NOTE: stale update marker (pid $(_mam_lock_field "$MARKER" pid)) taken over; its update is gone"
fi
marker_rc=0
mam_acquire_loop_lock "$MARKER" || marker_rc=$?
exec 7>&-
case "$marker_rc" in
0) ;;
3) echo "ERROR: another update is in progress (pid $(_mam_lock_field "$MARKER" pid)); nothing was changed" >&2; exit 1 ;;
*) echo "ERROR: cannot tell whether another update is in progress (ps unavailable?); nothing was changed" >&2; exit 1 ;;
esac
TMPD=""
MONITOR_WAS_RUNNING=0
RESTART_MONITOR=0
_phase1_exit() {
local rc=$?
if [ "$RESTART_MONITOR" = "1" ]; then
_restart_monitor || true
fi
mam_release_loop_lock "$MARKER" || true
[ -z "$TMPD" ] || rm -rf "$TMPD"
exit "$rc"
}
trap _phase1_exit EXIT
_restart_monitor() {
if [ "${MAM_NO_SUBSCRIBER_DAEMON:-0}" = "1" ]; then
echo "monitor: not restarted (MAM_NO_SUBSCRIBER_DAEMON=1)"
return 0
fi
local pid
pid=$(start_watchdog update "$WS") || { echo "ERROR: could not restart the monitor daemon" >&2; return 1; }
echo "monitor: restarted (PID $pid)"
}
_monitor_json() {
"$PY" -m lib_py.update monitor --workspace "$WS"
}
_json_field() {
"$PY" -c 'import json,sys; v=json.load(sys.stdin)[sys.argv[1]]; print(" ".join(v) if isinstance(v, list) else v)' "$1"
}
# --- pre-check: refuse before any change (decisions 1, 7; U3) ---
BLOCKERS=""
_block() { BLOCKERS="${BLOCKERS} - $1
"; }
if [ -f "$MAM/loop-guard-active" ]; then
loop_st="$(_mam_lock_holder_state "$MAM/loop-guard-active")"
case "$loop_st" in
stale) echo "NOTE: stale loop marker $MAM/loop-guard-active (its loop is gone); not a reason to refuse" ;;
*) _block "a review loop is running (pid $(_mam_lock_field "$MAM/loop-guard-active" pid), holder $loop_st); wait for it to end" ;;
esac
fi
STATE_JSON=""
if ! STATE_JSON="$(load_state_json strict)"; then
_block "the registry $AGENT_SESSIONS_YAML cannot be read; fix it first"
STATE_JSON='{}'
fi
REAL_HERDR="$(_resolve_real_herdr_path || true)"
if [ -z "$REAL_HERDR" ]; then
_block "no herdr binary found on PATH, so the live agents cannot be listed"
else
pre_out=""
pre_rc=0
pre_out="$(MAM_STATE_JSON="$STATE_JSON" "$PY" -m lib_py.update precheck --workspace "$WS" --herdr "$REAL_HERDR")" || pre_rc=$?
if [ "$pre_rc" -ne 0 ]; then
while IFS= read -r line; do
[ -n "$line" ] && _block "$line"
done <<< "${pre_out:-lib_py.update precheck failed (rc $pre_rc)}"
fi
fi
MON_JSON="$(_monitor_json)" || { _block "cannot inspect the monitor daemon (lib_py.update monitor)"; MON_JSON='{"pids": [], "lock": "free"}'; }
MON_PIDS="$(printf '%s' "$MON_JSON" | _json_field pids)"
MON_LOCK="$(printf '%s' "$MON_JSON" | _json_field lock)"
if [ -z "$MON_PIDS" ] && [ "$MON_LOCK" = "held" ]; then
_block "$MAM/monitor.lock is held by a monitor the skills did not start (not 'bash $WS/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh --subscribe'); stop it (find it with: pgrep -af reconcile.sh) and try again"
fi
if [ -n "$BLOCKERS" ]; then
echo "ERROR: update refused; nothing was changed:" >&2
printf '%s' "$BLOCKERS" >&2
exit 1
fi
# --- stop the monitor daemon (decision 2) ---
_monitor_gone() {
local p
for p in "$@"; do
kill -0 "$p" 2>/dev/null && return 1
done
[ "$(_monitor_json | _json_field lock)" = "free" ]
}
_wait_monitor_gone() {
local deadline=$(( $(date +%s) + $1 ))
shift
while :; do
_monitor_gone "$@" && return 0
[ "$(date +%s)" -lt "$deadline" ] || return 1
sleep 0.5
done
}
if [ -n "$MON_PIDS" ]; then
MONITOR_WAS_RUNNING=1
# shellcheck disable=SC2086
set -- $MON_PIDS
kids=""
for p in "$@"; do kids="$kids $(pgrep -P "$p" 2>/dev/null | tr '\n' ' ' || true)"; done
echo "monitor: stopping the daemon (PID $MON_PIDS)"
kill -TERM "$@" 2>/dev/null || true
if ! _wait_monitor_gone "$(mam_int_knob MAM_UPDATE_MONITOR_STOP_S 30 1)" "$@"; then
for p in "$@"; do kids="$kids $(pgrep -P "$p" 2>/dev/null | tr '\n' ' ' || true)"; done
echo "WARNING: the monitor did not stop within MAM_UPDATE_MONITOR_STOP_S; sending SIGKILL to $* $kids" >&2
# shellcheck disable=SC2086
kill -KILL "$@" $kids 2>/dev/null || true
# shellcheck disable=SC2086
if _wait_monitor_gone 5 "$@" $kids; then
echo "ERROR: the monitor did not stop in time; it was killed and is started again. Nothing else was changed." >&2
_restart_monitor || true
exit 1
fi
echo "ERROR: could not restore the monitor state: PIDs still alive or monitor.lock still held ($*$kids; lock $(_monitor_json | _json_field lock)). Nothing else was changed." >&2
exit 1
fi
RESTART_MONITOR=1
fi
TMPD="$(mktemp -d "${TMPDIR:-/tmp}/mam-update.XXXXXX")"
cp "$SCRIPT_DIR/install_phase.sh" "$TMPD/install_phase.sh"
# --- a leftover backup of an update that died (U4) ---
if [ -e "$BK" ]; then
left_st="$(cat "$BK/state" 2>/dev/null || true)"
case "$left_st" in
""|backing-up|installed)
echo "NOTE: removing the backup a previous update left (state ${left_st:-none}); nothing to restore"
rm -rf "$BK" ;;
installing)
echo "NOTE: a previous update died during its install; restoring its backup (the registry is left as it is)"
if ! bash "$TMPD/install_phase.sh" restore "$WS" --skip-registry; then
RESTART_MONITOR=0
echo "ERROR: restoring the previous update's backup failed; it is kept at $BK. Nothing more was changed; the monitor stays stopped." >&2
exit 1
fi
rm -rf "$BK"
echo "the previous update was rolled back" ;;
*)
echo "ERROR: $BK has an unknown state '$left_st'; it is kept and nothing was changed. Check it and remove it by hand." >&2
exit 1 ;;
esac
fi
# --- the rollback backup (decisions 3, 10; U4) ---
_set_state() { printf '%s\n' "$1" > "$BK/state.tmp" && mv -f "$BK/state.tmp" "$BK/state"; }
ITEMS=".agents .mam_deploy AGENTS.md MESSAGING.md BOOTSTRAP.md BOOTSTRAP.ko.md .mam.env.example .mam.env .gitignore remove.sh update.sh .venv .mam/install_manifest.txt .mam/install_state .mam/asset_hashes.txt .mam/version.txt .mam/base .mam/skill-backups"
_backup() {
mkdir -p "$BK/files" || return 1
_set_state backing-up || return 1
: > "$BK/items" && : > "$BK/absent" || return 1
local rel
for rel in $ITEMS; do
printf '%s\n' "$rel" >> "$BK/items" || return 1
if [ -e "$WS/$rel" ] || [ -L "$WS/$rel" ]; then
mkdir -p "$BK/files/$(dirname "$rel")" && cp -pPR "$WS/$rel" "$BK/files/$rel" || return 1
else
printf '%s\n' "$rel" >> "$BK/absent" || return 1
fi
done
"$PY" -m lib_py.update backup-registry --workspace "$WS" --dest "$BK/registry" >/dev/null
}
echo "backup: $BK"
if ! _backup; then
rm -rf "$BK"
echo "ERROR: the backup could not be made; nothing was changed" >&2
exit 1
fi
# --- hand over to the copy (decision 5): same pid, so the marker stays live ---
RESTART_MONITOR=0
trap - EXIT
exec bash "$TMPD/install_phase.sh" run "$WS" "$MONITOR_WAS_RUNNING" "$TMPD"
+1 -1
View File
@@ -2,7 +2,7 @@
본 문서는 `herdr_agent_orchestration` 오케스트레이션 및 메시징 백플레인 워크플로우를 새로운 프로젝트에 도입하여 이식하고, 새로운 개발자/에이전트가 초기 가동을 시작할 때 수행해야 하는 환경 설정 및 구축 절차를 안내합니다.
[![Version: v4.10.0](https://img.shields.io/badge/version-v4.10.0-blue.svg)](VERSIONS.md)
[![Version: v4.11.0](https://img.shields.io/badge/version-v4.11.0-blue.svg)](VERSIONS.md)
새로운 에이전트는 이 안내서에 기술된 절차를 순차적으로 실행하여 초기 환경을 안정적으로 설정할 수 있습니다.
+1 -1
View File
@@ -2,7 +2,7 @@
This document guides you through the setup and initialization procedures required to adopt the `herdr_agent_orchestration` orchestration and messaging backplane workflow in a new project, enabling a new developer or agent to get up and running quickly.
[![Version: v4.10.0](https://img.shields.io/badge/version-v4.10.0-blue.svg)](VERSIONS.md)
[![Version: v4.11.0](https://img.shields.io/badge/version-v4.11.0-blue.svg)](VERSIONS.md)
A new agent can follow the steps in this guide sequentially to establish a stable and reliable initial environment.
+1 -1
View File
@@ -1,6 +1,6 @@
# Messaging System Technical Analysis & Architecture Report
Applies to: MAM v4.10.0
Applies to: MAM v4.11.0
This report provides a comprehensive, deep-dive analysis of the messaging system implemented in the `multi-agent-mux-delegate-job` skill. It covers the MQTT broker architecture, event protocols, job lifecycles, codebase internals, cross-system integration, and a list of known limitations along with production recommendations.
+4 -3
View File
@@ -2,7 +2,7 @@
[herdr](https://herdr.dev)와 MQTT 위에 만든 **다중 에이전트 오케스트레이션·메시징 백플레인**입니다. 장시간 실행되는 코딩 에이전트(`claude`, `agy`, `hermes`, `grok`, `opencode`, `codex`)를 격리된 herdr pane 에서 돌리고, 작업을 추적되는 잡으로 위임하며, 모든 리뷰어가 통과시킬 때까지 planner / creator / reviewer 루프를 돌립니다.
[![Version: v4.10.0](https://img.shields.io/badge/version-v4.10.0-blue.svg)](VERSIONS.md)
[![Version: v4.11.0](https://img.shields.io/badge/version-v4.11.0-blue.svg)](VERSIONS.md)
---
@@ -38,7 +38,7 @@ bash deploy/install.sh
- `MQTT_BROKER=localhost` 로 된 초기 `.mam.env` 를 씁니다. 이미 있는 `.mam.env` 는 그대로 둡니다. 예전 `.env` 만 있으면 그것을 `.mam.env` 로 복사하고 원본은 남깁니다.
- `.venv`, `.mam/`, `.mam.env` 를 `.gitignore` 에 추가합니다.
`.mam.env` 의 `MQTT_BROKER` 를 직접 운영하는 브로커로 바꾸십시오(사설 브로커는 [MESSAGING.md](MESSAGING.md) 참고). `MQTT_BROKER` 가 아예 설정되지 않으면 코드는 공개 브로커 `broker.hivemq.com` 을 씁니다. 시험용으로는 괜찮지만 실제 작업용은 아닙니다. `deploy/generate-env.sh` 는 `.mam.env` 가 없을 때 `.mam.env.example` 로 만듭니다(`--force` 는 `.mam.env.bak` 을 남기고 덮어씁니다). 업데이트와 제거는 `deploy/update.sh`, `deploy/remove.sh` 입니다. `deploy/install_mam.sh` 는 로컬 클론에서 다른 디렉터리로 복사해 설치하는 별도 설치기입니다.
`.mam.env` 의 `MQTT_BROKER` 를 직접 운영하는 브로커로 바꾸십시오(사설 브로커는 [MESSAGING.md](MESSAGING.md) 참고). `MQTT_BROKER` 가 아예 설정되지 않으면 코드는 공개 브로커 `broker.hivemq.com` 을 씁니다. 시험용으로는 괜찮지만 실제 작업용은 아닙니다. `deploy/generate-env.sh` 는 `.mam.env` 가 없을 때 `.mam.env.example` 로 만듭니다(`--force` 는 `.mam.env.bak` 을 남기고 덮어씁니다). 업데이트는 `multi-agent-mux-update` 스킬, 제거는 `deploy/remove.sh` 입니다. `deploy/install_mam.sh` 는 로컬 클론에서 다른 디렉터리로 복사해 설치하는 별도 설치기입니다.
---
@@ -56,6 +56,7 @@ bash deploy/install.sh
| **`multi-agent-mux-delegate-job`** | 세션(`herdr:<name>`)이나 다른 호스트의 러너(`remote:<name>`)에 잡을 위임하고 결과를 기다립니다. [위임 계약](#-위임-계약)을 보십시오. |
| **`multi-agent-mux-loop`** | 계획·구현·리뷰: 선택적인 planner 와 creator, 그다음 선택한 리뷰어(`--reviewer A,B`, 또는 `--all-reviewer` 로 creator 를 뺀 실행 중인 리뷰어 전원)가 모두 `PASS` 를 낼 때까지(또는 `--max-loop` 까지). 둘 다 없으면 creator 가 스스로 리뷰합니다. 루프 밖에서 끝난 작업은 `--review-only` 로 리뷰합니다. 통과한 리포트는 `.agents/reports/<session>/` 으로 옮겨 보관합니다. |
| **`multi-agent-mux-orc-onboard`** | 오케스트레이터 자신의 세션 ID 를 등록해, 세션 탐색이 그것을 하위 에이전트로 착각하지 않게 합니다. |
| **`multi-agent-mux-update`** | 설치된 작업 공간을 제자리에서 업데이트합니다. 실행 중인 세션·열린 잡·루프가 있으면 거부하고, 모니터를 멈췄다가 원래대로 되돌리며, 설치가 실패하면 되돌리고, 새 코드로 레지스트리를 변환합니다. |
---
@@ -195,7 +196,7 @@ sequenceDiagram
│ │ └── *.py # pane_lock, stall_watch, worktree, herdr_config, ...
│ └── multi-agent-mux-*/ # 스킬마다 디렉터리 하나 (SKILL.md + scripts/)
├── .mam/ # 런타임 상태 (git 제외): 세션, 잡, 감사 로그, shim
├── deploy/ # install.sh, install_mam.sh, update.sh, remove.sh, generate-env.sh
├── deploy/ # install.sh, install_mam.sh, remove.sh, generate-env.sh
├── docs/ # contracts/, design/, 에이전트 통합·브리지 가이드
├── nats-docker/ # 사설 NATS 브로커 배포 (서브모듈)
├── tests/ # pytest 스위트
+4 -3
View File
@@ -2,7 +2,7 @@
A **Multi-Agent Orchestration & Messaging Backplane** built on [herdr](https://herdr.dev) and MQTT. It runs long-lived coding agents (`claude`, `agy`, `hermes`, `grok`, `opencode`, `codex`) in isolated herdr panes, delegates work to them as tracked jobs, and drives planner / creator / reviewer loops until every reviewer passes the change.
[![Version: v4.10.0](https://img.shields.io/badge/version-v4.10.0-blue.svg)](VERSIONS.md)
[![Version: v4.11.0](https://img.shields.io/badge/version-v4.11.0-blue.svg)](VERSIONS.md)
---
@@ -38,7 +38,7 @@ bash deploy/install.sh
- writes a starter `.mam.env` with `MQTT_BROKER=localhost`. An existing `.mam.env` is kept. If only a legacy `.env` exists, it is copied to `.mam.env` and the original stays;
- adds `.venv`, `.mam/` and `.mam.env` to `.gitignore`.
Point `MQTT_BROKER` in `.mam.env` at a broker you run (see [MESSAGING.md](MESSAGING.md) for a private broker). If `MQTT_BROKER` is not set at all, the code falls back to the public `broker.hivemq.com`. That is fine for a trial, but not for real work. `deploy/generate-env.sh` creates `.mam.env` from `.mam.env.example` when it is missing (`--force` overwrites it, keeping a `.mam.env.bak`). `deploy/update.sh` and `deploy/remove.sh` update and uninstall. `deploy/install_mam.sh` is a separate installer that copies from a local clone into another directory.
Point `MQTT_BROKER` in `.mam.env` at a broker you run (see [MESSAGING.md](MESSAGING.md) for a private broker). If `MQTT_BROKER` is not set at all, the code falls back to the public `broker.hivemq.com`. That is fine for a trial, but not for real work. `deploy/generate-env.sh` creates `.mam.env` from `.mam.env.example` when it is missing (`--force` overwrites it, keeping a `.mam.env.bak`). The `multi-agent-mux-update` skill updates an installed workspace, and `deploy/remove.sh` uninstalls. `deploy/install_mam.sh` is a separate installer that copies from a local clone into another directory.
---
@@ -56,6 +56,7 @@ Every skill lives under `.agents/skills/` with a `SKILL.md` that documents its o
| **`multi-agent-mux-delegate-job`** | Delegates a job to a session (`herdr:<name>`) or to a runner on another host (`remote:<name>`) and waits for its result. See [Delegation contract](#-delegation-contract). |
| **`multi-agent-mux-loop`** | Plan, implement, review: an optional planner and a creator, then the selected reviewers (`--reviewer A,B`, or `--all-reviewer` for every running reviewer except the creator) until all of them `PASS` or `--max-loop` is reached. With neither option the creator reviews its own work. `--review-only` reviews work finished outside the loop. Passing reports are promoted to `.agents/reports/<session>/`. |
| **`multi-agent-mux-orc-onboard`** | Registers the orchestrator's own session id, so session discovery never mistakes it for a sub-agent. |
| **`multi-agent-mux-update`** | Updates an installed workspace in place: refuses while sessions, open jobs or a loop are live; stops and restores the monitor; rolls back on a failed install; converts the registry with the new code. |
---
@@ -195,7 +196,7 @@ sequenceDiagram
│ │ └── *.py # pane_lock, stall_watch, worktree, herdr_config, ...
│ └── multi-agent-mux-*/ # One directory per skill (SKILL.md + scripts/)
├── .mam/ # Runtime state (git-ignored): sessions, jobs, audit logs, shim
├── deploy/ # install.sh, install_mam.sh, update.sh, remove.sh, generate-env.sh
├── deploy/ # install.sh, install_mam.sh, remove.sh, generate-env.sh
├── docs/ # contracts/, design/, agent integration and bridge guides
├── nats-docker/ # Private NATS broker deployment (submodule)
├── tests/ # pytest suite
+56 -11
View File
@@ -6,8 +6,8 @@
## 📌 현재 버전 개요 (Current Release)
- **프레임워크 버전**: `v4.10.0`
- **최신 릴리스 일시**: 2026-10-23 (KST)
- **프레임워크 버전**: `v4.11.0`
- **최신 릴리스 일시**: 2026-10-27 (KST)
- **기준 브랜치**: `main`
- **핵심 아키텍처**:
- **Terminal 상태 단방향 불변성 (`engine.py`, Issue #6 Phase 1)**: `completed`, `error`, `cancelled` 터미널 상태 진입 후 후속 이벤트나 업데이트에 의한 덮어쓰기를 엄격히 차단(Terminal Immutability). `registry.py close` 서브커맨드 및 fail-closed 조기 종료 도입.
@@ -24,23 +24,68 @@
## 🧭 스킬 패키지 버전 매트릭스 (Skills Version Matrix)
모든 8개 스킬은 YAML frontmatter 메타데이터(`author`, `version`, `platforms`, `environments`) 표준화를 통해 `v4.10.0`로 동기화되어 배포됩니다.
모든 8개 스킬은 YAML frontmatter 메타데이터(`author`, `version`, `platforms`, `environments`) 표준화를 통해 `v4.11.0`로 동기화되어 배포됩니다.
| 스킬명 | 버전 | 역할 및 주요 책임 | 상태 |
| :--- | :---: | :--- | :---: |
| **`multi-agent-mux-create`** | `4.10.0` | 에이전트 세션 신규 생성 및 Herdr 컨테이너 격리 스폰 | ✅ 배포 |
| **`multi-agent-mux-stop`** | `4.10.0` | 대화 UUID 원자적 캡처 및 세션 안전 종료 (Graceful Stop) | ✅ 배포 |
| **`multi-agent-mux-resume`** | `4.10.0` | 온디스크 대화 컨텍스트 기반 Tier-1 초고속 세션 복원 | ✅ 배포 |
| **`multi-agent-mux-status`** | `4.10.0` | 실시간 Herdr 세션 및 레지스트리 드리프트 스냅샷 조회 | ✅ 배포 |
| **`multi-agent-mux-monitor`** | `4.10.0` | YAML ↔ 런타임 상태 간 자율 조정자 (Reconciler Loop) | ✅ 배포 |
| **`multi-agent-mux-delegate-job`** | `4.10.0` | MQTT 이벤트 채널 기반 비동기 단위 작업 위임 | ✅ 배포 |
| **`multi-agent-mux-loop`** | `4.10.0` | Planner-Creator-Reviewer 3자 자율 계획·실행·피어리뷰 루프 | ✅ 배포 |
| **`multi-agent-mux-orc-onboard`** | `4.10.0` | 오케스트레이터 UUID 격리 등록 및 서브 세션 오염 방지 | ✅ 배포 |
| **`multi-agent-mux-create`** | `4.11.0` | 에이전트 세션 신규 생성 및 Herdr 컨테이너 격리 스폰 | ✅ 배포 |
| **`multi-agent-mux-stop`** | `4.11.0` | 대화 UUID 원자적 캡처 및 세션 안전 종료 (Graceful Stop) | ✅ 배포 |
| **`multi-agent-mux-resume`** | `4.11.0` | 온디스크 대화 컨텍스트 기반 Tier-1 초고속 세션 복원 | ✅ 배포 |
| **`multi-agent-mux-status`** | `4.11.0` | 실시간 Herdr 세션 및 레지스트리 드리프트 스냅샷 조회 | ✅ 배포 |
| **`multi-agent-mux-monitor`** | `4.11.0` | YAML ↔ 런타임 상태 간 자율 조정자 (Reconciler Loop) | ✅ 배포 |
| **`multi-agent-mux-delegate-job`** | `4.11.0` | MQTT 이벤트 채널 기반 비동기 단위 작업 위임 | ✅ 배포 |
| **`multi-agent-mux-loop`** | `4.11.0` | Planner-Creator-Reviewer 3자 자율 계획·실행·피어리뷰 루프 | ✅ 배포 |
| **`multi-agent-mux-orc-onboard`** | `4.11.0` | 오케스트레이터 UUID 격리 등록 및 서브 세션 오염 방지 | ✅ 배포 |
---
## 📋 버전별 상세 변경 내역 (Changelog)
### 🚀 `v4.11.0` — attach 명령은 herdr 에 등록된 이름을 쓰고, 세션 시작·종료는 스킬로만 한다 (Issue #71) (2026-10-27)
> **마이너 (MINOR Release)**: 레지스트리의 `attach_command` 가 원래 세션 이름으로 herdr 를 불러, 이름이 herdr 의 규칙(소문자 `[a-z0-9_-]`, 32자 이하)을 벗어나면 붙지 못했다. 이제 attach 명령은 한 함수가 만들고, 레지스트리 행에서 `kill_command`·`start_command` 를 지웠으며, 레지스트리에 형식 버전(`registry_format: 2`)을 더했다. `status --json` 의 키가 빠지고 stop 의 동작(행 없는 세션 닫기)이 늘어 MINOR 로 올린다. PR #73.
#### ⚠️ 호환성
- 레지스트리 행에 `kill_command`·`start_command` 가 더 이상 없다. `status --json` 에서도 `start_command` 키가 빠졌다.
- `attach_command` 는 `herdr --session <server> agent attach <herdr 에 등록된 이름>` 이다. `default` 서버도 적는다.
- 옛 레지스트리(형식 키 없음 = 1)는 첫 쓰기 때 2 로 바뀐다. 첫 쓰기 전까지 status 에 옛 행이 보일 수 있다.
- 세션 시작은 create·resume, 종료는 stop 스킬로 한다. attach 만 보기 전용이라 herdr 명령을 직접 쓴다.
#### 🌟 주요 개선 사항
1. **attach 명령 하나 (`lib_py/agents/sanitize.py` `attach_command`)**: create·resume·reconcile 이 모두 이 함수를 쓴다. 값은 `shlex.quote` 로 감싼다. lib.sh 래퍼의 `herdr agent attach <원래 이름>` 도 이름을 바꿔 부르고, 대상 뒤의 옵션(`--takeover`)은 그대로 넘긴다.
2. **행이 없는 세션의 stop (`stop_session.sh`)**:
- 레지스트리에 행이 없으면 herdr 세션만 닫고 레지스트리는 바꾸지 않는다. herdr 가 기록한 cwd 가 이 작업 공간 안일 때만 닫는다.
- 다른 작업 공간의 세션은 exit 5, 서버에 없으면 exit 1, 닫은 뒤에도 살아 있으면 exit 6 이다.
- 레지스트리를 읽지 못하면(YAML 깨짐, DB 손상, 테이블 없음) "행 없음" 으로 보지 않고 exit 1 로 멈춘다(`load_state_json strict`).
- 사용자에게 보여 주던 날 `kill-session` 안내 두 곳(create exit 43, `id_order.py`)은 stop 스킬 명령으로 바꿨다.
3. **이름 변환 규칙 하나 (`lib.sh`)**: `_sanitize_herdr_agent_name` 정의를 하나로 합쳤고 shim 은 `declare -f` 로 같은 함수를 받는다. `has-session` 의 `lower()[:32]` 대체 경로를 지웠다. 해시 도구가 없으면 python3 로, python3 도 없으면 오류로 멈춘다(`00000000` 을 붙이지 않는다). ASCII 가 아닌 이름·`-n`·줄바꿈이 든 이름도 Python 함수와 결과가 같다.
4. **레지스트리 형식 버전 2 (`lib_py/registry_format.py`, `lib_py/atomic_yaml.py`)**: 레지스트리를 쓰는 함수가 잠금(`BEGIN IMMEDIATE`) 안에서, 호출자의 변경 전에 모든 행을 바꾸고 YAML 도 다시 쓴다. 업데이트 뒤 부를 진입점: `PYTHONPATH=.agents/skills python3 -m lib_py.registry_format --yaml <agent-sessions.yaml>` (#72 에서 쓴다).
5. **stall_watch**: herdr 이름을 `StallWatch` 에서 한 번만 구해 `HerdrSource` 에 넘긴다.
#### ✅ 품질 검증
- **회귀**: `tests/test_issue71_herdr_names.py`(70개). base(v4.10.1)에서 68개가 실패한다.
- **리뷰 (`--all-reviewer`, 작성자 PR 의 리뷰 전용 루프)**: 1회차 3/4 PASS. codex 의 지적(행 없는 stop 의 `HERDR_SESSION_NAME=default` 처리)은 create 와 같은 서버 선택 규칙이라는 반론으로 철회됐다. 2회차(@5f60653) **4/4 PASS**. 리뷰어들이 macOS bash 3.2 에서 이름 변환이 Python 함수와 같은 것을 `LC_ALL=C`·UTF-8 양쪽에서 확인했다. 리포트는 `.agents/reports/<session>/` 에 있다.
- **전체 스위트 (macOS, Python 3.9.6, @5f60653)**: 2173 passed, 56 skipped, 2 failed. 실패는 부하에 민감한 타이밍 테스트 둘(`test_issue14_delivery_ack.py::test_wrapper_idle_timeout_grace_ignores_pane_busy_token`, `test_issue30_stall_watch.py::test_s13_unreliable_agent_status_turns_idle_off_but_keeps_blocked`)이고, 단독으로 3번 모두, 두 파일 전체로도 통과했다(60 passed, 1 skipped). 실제 `~/.claude`·`/private/tmp/claude-<uid>`·herdr 서버 pane 목록·opencode 의 data·state·config 에 이 실행이 남긴 것은 없었다.
- **작성자 확인 (Linux, Python 3.14)**: 2211 passed, 20 skipped, 실패 0.
- **알려진 비차단**: `name` 이 없는 행은 형식 변환에서 건너뛰므로 두 필드가 남을 수 있다. delegate-job 래퍼의 위임 안내 문구(`작업이 세션 ...에 전송되었습니다`)는 아직 옛 attach 형태(`HERDR_SESSION_NAME=... herdr agent attach <원래 이름>`)를 보여 준다.
- **병합**: merge commit 으로 병합한다. `test_issue23_24_startup_path.py` S11 이 이 브랜치의 커밋 `0fc162e` 를 기준으로 쓴다.
### 🚀 `v4.10.1` — stall_watch 가 herdr 에 변환된 에이전트 이름을 쓴다 (Issue #66) (2026-10-25)
> **패치 (PATCH Release)**: 세션 이름이 herdr 의 에이전트 이름 규칙(소문자 `[a-z0-9_-]`, 영문자로 시작, 32자 이하)을 벗어나면, 그 세션에 위임한 잡의 `stall_watch` 가 pane 을 찾지 못하고(`stall_watch:agent_not_found`) 멈춤 알림이 꺼졌다. 이제 herdr 를 부를 때만 `sanitize_herdr_agent_name()` 의 결과를 쓴다. 옵션·동작 계약은 바뀌지 않는다. PR #70.
#### 🌟 주요 개선 사항
1. **herdr 호출 (`lib_py/stall_watch.py`)**: `HerdrSource` 의 `agent get`·`agent read`·`agent prompt` 와 `StallWatch` 의 `watcher.wait_once` 가 변환된 이름을 쓴다. 32자를 넘는 이름(앞 23자 + `-` + sha1 8자), 대문자·`.` 이 든 이름이 해당한다. 이미 규칙에 맞는 이름은 그대로다.
2. **원래 이름이 남는 곳**: 로그와 알림은 원래 세션 이름을 쓴다. 에이전트 종류(`resolve_agent`)도 원래 이름의 `-<agent>` 접미사로 정한다. 긴 이름은 변환되면 접미사가 해시로 바뀌기 때문이다.
3. **mock herdr (`tests/conftest.py`)**: `strict_agent_names` 옵션을 켜면 실제 herdr 처럼 등록된(변환된) 이름에만 답하고, 다른 이름에는 `agent_not_found` 를 낸다. 기본은 꺼져 있어 기존 테스트의 동작은 같다.
#### ✅ 품질 검증
- **회귀**: `tests/test_issue66_stall_watch_sanitized_name.py`(7개). base(v4.10.0)에서 5개가 실패한다. 긴 이름·대문자 이름의 idle 재촉과 blocked 알림, 원래 이름의 로그, 접미사로 정하는 에이전트 종류, 규칙에 맞는 이름의 무변경을 확인한다.
- **리뷰 (`--all-reviewer`, 작성자 PR 의 리뷰 전용 루프)**: 1회차(@bb496fd) **4/4 PASS**. lib.sh `_sanitize_herdr_agent_name` 과 Python 함수의 결과가 같은 것도 확인했다. 리포트는 `.agents/reports/<session>/` 에 있다.
- **전체 스위트 (macOS, Python 3.9.6, @bb496fd)**: 2105 passed, 56 skipped, 실패 0. 실제 `~/.claude`·`/private/tmp/claude-<uid>`·herdr 서버 pane 목록·opencode 의 data·state·config 에 이 실행이 남긴 것은 없었다.
- **작성자 확인 (Linux)**: 전체 스위트 2140 passed, 20 skipped, 1 failed. 실패는 `tests/test_issue17_item4_decisions.py` 의 정리 경합 하나이고 단독으로 10번 모두 통과했다.
- **알려진 비차단**: lib.sh 에 `_sanitize_herdr_agent_name` 정의가 두 벌(:36, :293) 있다. 뒤의 것이 적용되고, sha 도구가 없을 때의 대처만 다르다. 이 PR 이전부터 있던 것이다.
### 🚀 `v4.10.0` — 같은 폴더의 같은 종류 에이전트가 서로의 대화 ID 를 쓰지 않는다 (Issue #63) (2026-10-23)
> **마이너 (MINOR Release)**: 같은 폴더에 ID 가 나중에 생기는 같은 종류 에이전트(agy·codex·hermes·opencode)가 둘 있으면, stop·purge·resume 이 `find_workspace_uuid` 의 추측으로 다른 행의 대화 ID 를 기록하거나 지우거나 이어받을 수 있었다. 이제 세 경로가 #40 의 판정을 같이 쓰고, 같은 폴더의 생성 순서를 정하는 새 대기 동작(`MAM_ID_ORDER_WAIT_S`)과 resume 의 새 거부(exit 1)가 추가되어 MINOR 로 올린다. PR #69.
+1 -1
View File
@@ -60,7 +60,7 @@ $ bash .agents/skills/multi-agent-mux-create/scripts/create_session.sh \
### 2) 세션 접속 (Attach)
백그라운드에서 구동된 에이전트 TUI 화면에 들어갑니다.
```bash
$ herdr session attach my-project-dev-claude
$ herdr --session multi-agent-mux agent attach my-project-dev-claude
```
* **화면 탈출**: 대화 중 세션을 유지한 채 터미널로 돌아오려면 `Ctrl + B`를 누른 뒤 `D` 키를 차례로 입력합니다.
+3 -3
View File
@@ -9,7 +9,7 @@ This directory contains packaging templates and installation scripts to deploy t
* **`install.sh`**: A self-contained, idempotent remote shell installer (via curl) that checks system requirements (`herdr`, `python3`), sets up a local python virtual environment (`.venv`), and performs 3-way refresh with key-level registry merge (Rev.2).
* **`install_mam.sh`**: A local-clone installer that copies rules/skills (`.agents/`), `AGENTS.md`, manifests, and asset hashes into target projects.
* **`lib_ownership.sh`**: Single source of truth for framework-owned files and key-level registry files.
* **`update.sh`**: In-place updater script installed into target `.mam_deploy/update.sh`.
* **Updates**: installed workspaces update with the `multi-agent-mux-update` skill (`bash .agents/skills/multi-agent-mux-update/scripts/update.sh`).
* **`remove.sh`**: Clean uninstaller script installed into target `.mam_deploy/remove.sh`.
* **`generate-env.sh`**: Environment configuration bootstrap helper.
* **`INSTALL.md`**: Detailed installation and quick-start user manual.
@@ -40,7 +40,7 @@ bash deploy/install_mam.sh --target /path/to/your/project
Refer to **`INSTALL.md`** inside this directory for the full instructions and workflows.
> [!NOTE]
> `install_mam.sh` automatically deploys `.mam_deploy/update.sh` and `.mam_deploy/remove.sh` into target workspaces, recording install manifests and asset hashes to enable clean updates and uninstalls.
> `install_mam.sh` automatically deploys `.mam_deploy/remove.sh` into target workspaces, recording install manifests and asset hashes to enable clean updates and uninstalls.
### 3. Custom Fork / Private Mirror Installations
If you run a private mirror or fork, you can override the source URLs during installation using environment variables:
@@ -53,7 +53,7 @@ curl -fsSL https://my-mirror.example.com/.../install.sh \
bash
# Updating an existing workspace against a mirror
MAM_INSTALLER_URL=https://my-mirror.example.com/.../install.sh bash deploy/update.sh
MAM_INSTALLER_URL=https://my-mirror.example.com/.../install.sh bash .agents/skills/multi-agent-mux-update/scripts/update.sh
```
### 4. Registering as a Workspace Plugin
+1 -1
View File
@@ -41,7 +41,7 @@ jobs:
shellcheck deploy/install.sh
shellcheck deploy/install_mam.sh
shellcheck deploy/generate-env.sh
shellcheck deploy/update.sh
shellcheck .agents/skills/multi-agent-mux-update/scripts/*.sh
shellcheck deploy/remove.sh
echo "✅ ShellCheck completed successfully."
+68 -18
View File
@@ -125,7 +125,9 @@ if [ "$REFRESH" -eq 1 ] || [ "${MAM_SKIP_VENV:-0}" -eq 1 ] || ! check_assets_pre
FETCH_METHOD="archive"
if [ -d "$REPO_URL" ]; then
echo "🌐 Copying local working tree into a staging area..."
cp -R "$REPO_URL/." "$STAGE_DIR/"
# Everything but .mam/: runtime state is never staged, and it may hold the
# update skill's rollback copy (.mam/update-backup/, Issue #72).
find "$REPO_URL/." -mindepth 1 -maxdepth 1 ! -name .mam -exec cp -R {} "$STAGE_DIR/" \;
FETCH_METHOD="local"
elif command -v git &>/dev/null; then
echo "🌐 Cloning repository (shallow) into a staging area..."
@@ -157,9 +159,16 @@ if [ "$REFRESH" -eq 1 ] || [ "${MAM_SKIP_VENV:-0}" -eq 1 ] || ! check_assets_pre
MANIFEST_FILE=".mam/install_manifest.txt"
touch "$MANIFEST_FILE"
# Migrate legacy root layout for remove.sh and update.sh into .mam_deploy/ if owned
# Drop every line naming this path from the manifest
manifest_drop() {
grep -Fvx -- "$1" "$MANIFEST_FILE" > "$MANIFEST_FILE.tmp" || true
mv -f "$MANIFEST_FILE.tmp" "$MANIFEST_FILE"
}
# Migrate legacy root layout for remove.sh into .mam_deploy/ if owned
mkdir -p .mam_deploy
for legacy_script in remove.sh update.sh; do
# shellcheck disable=SC2043 # one legacy script left (update.sh went with Issue #72)
for legacy_script in remove.sh; do
if [ -f "$legacy_script" ] && grep -Fqx "$legacy_script" "$MANIFEST_FILE" 2>/dev/null; then
mv -f "$legacy_script" ".mam_deploy/$legacy_script"
python3 -c '
@@ -179,6 +188,13 @@ with open(path, "w") as f:
fi
done
# update.sh is no longer shipped (Issue #72; the multi-agent-mux-update skill
# updates). A legacy root update.sh goes only if the manifest says we put it there.
if [ -f update.sh ] && grep -Fqx update.sh "$MANIFEST_FILE" 2>/dev/null; then
rm -f update.sh
manifest_drop update.sh
fi
# Safe refresh & fingerprint checking logic
TS=$(date -u +%Y%m%dT%H%M%SZ)
FRAMEWORK_LEDGER=".mam/.framework_ledger.tmp"
@@ -374,7 +390,7 @@ MERGEREG
fi
done
# Install remove.sh and update.sh into .mam_deploy/
# Install remove.sh into .mam_deploy/
mkdir -p .mam_deploy
if [ -f "$STAGE_DIR/deploy/remove.sh" ]; then
cp "$STAGE_DIR/deploy/remove.sh" .mam_deploy/remove.sh
@@ -384,13 +400,9 @@ MERGEREG
fi
fi
if [ -f "$STAGE_DIR/deploy/update.sh" ]; then
cp "$STAGE_DIR/deploy/update.sh" .mam_deploy/update.sh
chmod 0755 .mam_deploy/update.sh
if ! grep -Fqx ".mam_deploy/update.sh" "$MANIFEST_FILE" 2>/dev/null; then
echo ".mam_deploy/update.sh" >> "$MANIFEST_FILE"
fi
fi
# An older install's updater is not shipped any more (Issue #72)
rm -f .mam_deploy/update.sh
manifest_drop .mam_deploy/update.sh
if [ -f "$STAGE_DIR/.mam.env.example" ] && [ ! -e ".mam.env.example" ]; then
cp "$STAGE_DIR/.mam.env.example" .
@@ -407,6 +419,34 @@ MERGEREG
fi
fi
# Framework-owned files the previous install recorded (manifest and
# asset_hashes.txt) that this version no longer ships (Issue #72). Unmodified
# ones are deleted. Modified ones are kept and become the user's file: they
# leave the manifest, so remove.sh leaves them alone, and asset_hashes.txt
# (rebuilt below from the shipped files only).
if [ -f .mam/asset_hashes.txt ]; then
SHIPPED_LIST=".mam/.shipped_paths.tmp"
cut -f2 "$FRAMEWORK_LEDGER" > "$SHIPPED_LIST"
while read -r _old_sha _old_path; do
[ -n "${_old_path:-}" ] || continue
is_framework_owned "$_old_path" || continue
grep -Fqx -- "$_old_path" "$MANIFEST_FILE" || continue
if grep -Fqx -- "$_old_path" "$SHIPPED_LIST"; then continue; fi
if [ -f "$_old_path" ] && [ "$(python3 -c 'import hashlib, sys; print(hashlib.sha256(open(sys.argv[1], "rb").read()).hexdigest())' "$_old_path")" != "$_old_sha" ]; then
echo "ℹ️ Local modification detected (no longer shipped, kept as yours): $_old_path" >&2
else
rm -f "$_old_path"
_old_dir="$(dirname "$_old_path")"
while [ "$_old_dir" != ".agents" ] && rmdir "$_old_dir" 2>/dev/null; do
_old_dir="$(dirname "$_old_dir")"
done
fi
rm -f ".mam/base/$_old_path"
manifest_drop "$_old_path"
done < .mam/asset_hashes.txt
rm -f "$SHIPPED_LIST"
fi
# Re-build asset_hashes.txt from the UPSTREAM copy of every framework-owned file
python3 - .mam/asset_hashes.txt "$FRAMEWORK_LEDGER" <<'HASHDB'
import os, hashlib, sys
@@ -590,15 +630,25 @@ with open(gi_path, "w") as f:
PY
if [ "${MAM_SKIP_GITIGNORE:-0}" -eq 0 ]; then
# Set the key: overwrite it in place, one line per key, other keys kept
python3 -c '
import os
os.makedirs(".mam", exist_ok=True)
lines = []
if os.path.exists(".mam/install_state"):
with open(".mam/install_state", "a") as f:
f.write("gitignore_created=1\n")
else:
os.makedirs(".mam", exist_ok=True)
with open(".mam/install_state", "w") as f:
f.write("gitignore_created=1\n")
with open(".mam/install_state") as f:
lines = f.read().splitlines()
out, done = [], False
for line in lines:
if line.startswith("gitignore_created="):
if done:
continue
line, done = "gitignore_created=1", True
out.append(line)
if not done:
out.append("gitignore_created=1")
with open(".mam/install_state", "w") as f:
f.write("\n".join(out) + "\n")
'
fi
@@ -617,6 +667,6 @@ cat <<EOF
$ bash .agents/skills/multi-agent-mux-status/scripts/status.sh
3. Update installed skills in the future:
$ bash .mam_deploy/update.sh
$ bash .agents/skills/multi-agent-mux-update/scripts/update.sh
--------------------------------------------------------------------------------
EOF
+3 -7
View File
@@ -132,16 +132,12 @@ if [ -f "$SRC_DIR/.mam.env.example" ]; then
log_ok "Copied .mam.env.example configuration template"
fi
# Deploy remove.sh and update.sh into .mam_deploy/ (R-3)
# Deploy remove.sh into .mam_deploy/ (R-3)
mkdir -p "$TARGET_DIR/.mam_deploy"
if [ -f "$SRC_DIR/deploy/remove.sh" ]; then
cp "$SRC_DIR/deploy/remove.sh" "$TARGET_DIR/.mam_deploy/remove.sh"
chmod 0755 "$TARGET_DIR/.mam_deploy/remove.sh"
fi
if [ -f "$SRC_DIR/deploy/update.sh" ]; then
cp "$SRC_DIR/deploy/update.sh" "$TARGET_DIR/.mam_deploy/update.sh"
chmod 0755 "$TARGET_DIR/.mam_deploy/update.sh"
fi
if [ -f "$SRC_DIR/deploy/generate-env.sh" ]; then
mkdir -p "$TARGET_DIR/scripts"
@@ -161,7 +157,7 @@ mkdir -p "$TARGET_DIR/.mam"
MANIFEST_FILE="$TARGET_DIR/.mam/install_manifest.txt"
: > "$MANIFEST_FILE"
( cd "$TARGET_DIR" && find .agents -type f -print ) >> "$MANIFEST_FILE"
for extra in ".mam.env.example" ".mam_deploy/remove.sh" ".mam_deploy/update.sh" \
for extra in ".mam.env.example" ".mam_deploy/remove.sh" \
"scripts/generate-env.sh"; do
if [ -e "$TARGET_DIR/$extra" ]; then
echo "$extra" >> "$MANIFEST_FILE"
@@ -327,7 +323,7 @@ cat <<EOF
--herdr-server multi-agent-mux
2. Attach to the running session:
$ HERDR_SERVER_NAME=multi-agent-mux herdr agent attach <session_name>
$ herdr --session multi-agent-mux agent attach <herdr name>
3. Gracefully stop the session:
$ bash .agents/skills/multi-agent-mux-stop/scripts/stop_session.sh \\
+1
View File
@@ -92,6 +92,7 @@ else
".agents/skills/multi-agent-mux-resume"
".agents/skills/multi-agent-mux-status"
".agents/skills/multi-agent-mux-stop"
".agents/skills/multi-agent-mux-update"
".agents/hooks"
".agents/hooks.json"
".agents/MULTI_AGENT_RULES.md"
-269
View File
@@ -1,269 +0,0 @@
#!/usr/bin/env bash
# ==============================================================================
# update.sh — Multi-Agent Mux (MAM) Orchestration Updater
# ==============================================================================
# Safely updates MAM skills, virtual environment, and docs to the latest version.
# Preserves user configuration (.env) and local metadata/jobs database (.mam).
# ==============================================================================
set -euo pipefail
TARGET_DIR=""
FORCE=0
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Parse arguments
while [[ $# -gt 0 ]]; do
case "$1" in
-y|--yes|--force)
FORCE=1
shift
;;
*)
TARGET_DIR="$1"
shift
;;
esac
done
if [ -z "$TARGET_DIR" ]; then
if [ "$(basename "$SCRIPT_DIR")" = ".mam_deploy" ]; then
TARGET_DIR="$(dirname "$SCRIPT_DIR")"
else
TARGET_DIR="$(pwd)"
fi
else
if [ "$(basename "$TARGET_DIR")" = ".mam_deploy" ]; then
TARGET_DIR="$(dirname "$TARGET_DIR")"
fi
if [ ! -d "$TARGET_DIR" ]; then
echo "❌ Error: Target directory '$TARGET_DIR' does not exist." >&2
exit 1
fi
TARGET_DIR="$(cd "$TARGET_DIR" && pwd)"
fi
echo "===================================================================="
echo "⚡ Starting Multi-Agent Mux (MAM) Update"
echo "📂 Target Workspace: $TARGET_DIR"
echo "===================================================================="
cd "$TARGET_DIR"
# 1. Verification of existing install (B-2: dual resolution)
REMOVER=""
for cand in ".mam_deploy/remove.sh" "remove.sh"; do
if [ -f "$cand" ]; then
REMOVER="$cand"
break
fi
done
if [ -z "$REMOVER" ]; then
echo "❌ Error: No MAM installation (remove.sh) found in '$TARGET_DIR'." >&2
echo " Please run install.sh first to set up the workspace." >&2
exit 1
fi
# Request confirmation if not forced
if [ $FORCE -eq 0 ]; then
echo "⚠️ WARNING: This will update MAM orchestration skills, virtual environment, "
echo " and docs to the latest version."
echo " (Your configuration, job history, and custom skills will be preserved)."
if [ ! -t 0 ]; then
echo "❌ Error: Non-interactive terminal detected. Please run with -y/--yes/--force." >&2
exit 1
fi
if ! read -p "❓ Proceed with update? [y/N]: " -r response; then
response="n"
fi
if [[ ! "$response" =~ ^[yY](es)?$ ]]; then
echo "❌ Update cancelled by user."
exit 0
fi
fi
# 1.5 Pre-capture legacy ownership before remove.sh deletes the manifest (M-4)
MAM_LEGACY_ENV_OWNED=0
if [ -f ".mam/install_manifest.txt" ] && grep -Fqx ".env" ".mam/install_manifest.txt" 2>/dev/null; then
MAM_LEGACY_ENV_OWNED=1
fi
export MAM_LEGACY_ENV_OWNED
# 2. Stage backups of user configurations and metadata to prevent deletion
echo "💾 Backing up configuration and database..."
HAS_ENV=0
ENV_BACKUP_SRC=""
ENV_BACKUP_TMP=""
if [ -f ".mam.env" ]; then
HAS_ENV=1
ENV_BACKUP_SRC=".mam.env"
ENV_BACKUP_TMP=".mam.env.update-tmp"
mv ".mam.env" "$ENV_BACKUP_TMP"
elif [ -f ".env" ]; then
HAS_ENV=1
ENV_BACKUP_SRC=".env"
ENV_BACKUP_TMP=".env.update-tmp"
mv ".env" "$ENV_BACKUP_TMP"
fi
HAS_MAM=0
if [ -d ".mam" ]; then
HAS_MAM=1
# Copy database and jobs to temporary backup outside of .mam.
# We do NOT move the .mam folder away so that remove.sh can still read .mam/install_manifest.txt!
mkdir -p .mam.update-tmp
# Copy SQLite databases and session files
for db in .mam/agent-sessions.*; do
if [ -f "$db" ]; then
cp -f "$db" .mam.update-tmp/
fi
done
# Copy jobs history
if [ -d ".mam/jobs" ] && [ "$(ls -A .mam/jobs 2>/dev/null)" ]; then
mkdir -p .mam.update-tmp/jobs
cp -rf .mam/jobs/* .mam.update-tmp/jobs/
fi
# Copy delegate logs
if [ -d ".mam/delegate_job_logs" ] && [ "$(ls -A .mam/delegate_job_logs 2>/dev/null)" ]; then
mkdir -p .mam.update-tmp/delegate_job_logs
cp -rf .mam/delegate_job_logs/* .mam.update-tmp/delegate_job_logs/
fi
# Copy manifest so we have a backup
if [ -f ".mam/install_manifest.txt" ]; then
cp -f .mam/install_manifest.txt .mam.update-tmp/
fi
# C12: Copy MAM state files across update cycle
for st in install_state asset_hashes.txt version.txt; do
if [ -f ".mam/$st" ]; then
cp -f ".mam/$st" .mam.update-tmp/
fi
done
if [ -d ".mam/skill-backups" ]; then
mkdir -p .mam.update-tmp/skill-backups
cp -rf .mam/skill-backups/* .mam.update-tmp/skill-backups/ 2>/dev/null || true
fi
fi
# Define trap to restore backup files on failure
restore_on_failure() {
echo "❌ Update failed. Reverting configuration and database to previous state..."
if [ $HAS_ENV -eq 1 ] && [ -n "$ENV_BACKUP_TMP" ] && [ -f "$ENV_BACKUP_TMP" ]; then
mv -f "$ENV_BACKUP_TMP" "$ENV_BACKUP_SRC" 2>/dev/null || true
fi
if [ $HAS_MAM -eq 1 ] && [ -d ".mam.update-tmp" ]; then
# Revert to old database/jobs backup by restoring .mam directory
rm -rf .mam 2>/dev/null || true
mkdir -p .mam
cp -f .mam.update-tmp/agent-sessions.* .mam/ 2>/dev/null || true
if [ -d ".mam.update-tmp/jobs" ]; then
cp -rf .mam.update-tmp/jobs .mam/ 2>/dev/null || true
fi
if [ -d ".mam.update-tmp/delegate_job_logs" ]; then
cp -rf .mam.update-tmp/delegate_job_logs .mam/ 2>/dev/null || true
fi
if [ -f ".mam.update-tmp/install_manifest.txt" ]; then
cp -f .mam.update-tmp/install_manifest.txt .mam/ 2>/dev/null || true
fi
for st in install_state asset_hashes.txt version.txt; do
if [ -f ".mam.update-tmp/$st" ]; then
cp -f ".mam.update-tmp/$st" .mam/ 2>/dev/null || true
fi
done
if [ -d ".mam.update-tmp/skill-backups" ]; then
mkdir -p .mam/skill-backups
cp -rf .mam.update-tmp/skill-backups/* .mam/skill-backups/ 2>/dev/null || true
fi
rm -rf .mam.update-tmp 2>/dev/null || true
fi
}
trap restore_on_failure EXIT
# 3. Perform uninstallation of existing files
echo "🗑️ Removing existing installation..."
# remove.sh will run in manifest mode because .mam/install_manifest.txt is still present.
# It will delete .agents/, documents, scripts, .venv, and .mam folder.
bash "$REMOVER" --force "$TARGET_DIR"
# 4. Fetch and run the latest installer from Gitea
echo "📥 Fetching and running the latest installer..."
# Note: MAM_*_URL environment variables are automatically inherited by the child installer bash process.
INSTALLER_URL="${MAM_INSTALLER_URL:-https://git.godopu.com/tmpl/multi-agent-mux/raw/branch/main/deploy/install.sh}"
if command -v curl &>/dev/null; then
curl -fsSL "$INSTALLER_URL" | bash -s -- "$TARGET_DIR"
elif command -v wget &>/dev/null; then
wget -qO- "$INSTALLER_URL" | bash -s -- "$TARGET_DIR"
else
echo "❌ Error: Neither 'curl' nor 'wget' is available to fetch the installer." >&2
exit 1
fi
# Disable failure trap since installation succeeded
trap - EXIT
# 5. Restore backups of configuration and database
echo "🔄 Restoring configuration and database..."
if [ $HAS_ENV -eq 1 ] && [ -n "$ENV_BACKUP_TMP" ] && [ -f "$ENV_BACKUP_TMP" ]; then
if [ "$MAM_LEGACY_ENV_OWNED" = "1" ] && [ "$ENV_BACKUP_SRC" = ".env" ] && [ ! -f ".mam.env" ]; then
mv -f "$ENV_BACKUP_TMP" ".mam.env"
chmod 0600 ".mam.env" 2>/dev/null || true
else
mv -f "$ENV_BACKUP_TMP" "$ENV_BACKUP_SRC"
fi
fi
if [ $HAS_MAM -eq 1 ]; then
if [ -d ".mam.update-tmp" ]; then
# The installer created a new .mam directory with a new manifest.
# We want to merge the old .mam database/jobs back while keeping the new manifest.
for db in .mam.update-tmp/agent-sessions.*; do
if [ -f "$db" ]; then
cp -f "$db" .mam/
fi
done
if [ -d ".mam.update-tmp/jobs" ] && [ "$(ls -A .mam.update-tmp/jobs 2>/dev/null)" ]; then
mkdir -p .mam/jobs
cp -rf .mam.update-tmp/jobs/* .mam/jobs/
fi
if [ -d ".mam.update-tmp/delegate_job_logs" ] && [ "$(ls -A .mam.update-tmp/delegate_job_logs 2>/dev/null)" ]; then
mkdir -p .mam/delegate_job_logs
cp -rf .mam.update-tmp/delegate_job_logs/* .mam/delegate_job_logs/
fi
# Issue #8: asset_hashes.txt and version.txt describe the tree the
# installer just wrote, so the pre-update copies must NOT come back here -
# restoring them made version.txt name the previous commit and remove.sh
# treat every updated file as locally modified (spurious backups). Only
# install_state carries history worth keeping: merge in any key the fresh
# install did not write (e.g. gitignore_created from the original install).
# The failure path (restore_on_failure) still puts all three back verbatim.
if [ -f ".mam.update-tmp/install_state" ]; then
touch .mam/install_state
# A fresh install_state without a final newline would glue the first
# merged key onto its last line.
if [ -s .mam/install_state ] && [ -n "$(tail -c1 .mam/install_state)" ]; then
printf '\n' >> .mam/install_state
fi
while IFS= read -r line || [ -n "$line" ]; do
case "$line" in
*=*) ;;
*) continue ;; # not a key=value line
esac
key="${line%%=*}"
[ -n "$key" ] || continue
# exact key match, no regex interpretation of the key
if ! awk -F= -v k="$key" '$1 == k { found = 1 } END { exit !found }' .mam/install_state; then
printf '%s\n' "$line" >> .mam/install_state
fi
done < .mam.update-tmp/install_state
fi
if [ -d ".mam.update-tmp/skill-backups" ]; then
mkdir -p .mam/skill-backups
cp -rf .mam.update-tmp/skill-backups/* .mam/skill-backups/ 2>/dev/null || true
fi
rm -rf ".mam.update-tmp"
fi
fi
echo "===================================================================="
echo "🎉 Update complete!"
echo "===================================================================="
+11
View File
@@ -470,6 +470,9 @@ def _sanitize_agent_name(name):
def _match_agent(k, target):
if not k or not target:
return False
# Issue #66: like the real herdr, answer only the registered (sanitized) name.
if state.get("strict_agent_names"):
return k == target
return k == target or _sanitize_agent_name(k) == target or _sanitize_agent_name(target) == k or _sanitize_agent_name(k) == _sanitize_agent_name(target)
args = sys.argv[1:]
@@ -1052,6 +1055,10 @@ elif cmd1 == "agent":
}))
sys.exit(0)
else:
if state.get("strict_agent_names"):
sys.stderr.write(json.dumps({"error": {"code": "agent_not_found",
"message": "agent " + name + " not found"}}) + "\\n")
sys.exit(1)
sys.stderr.write("Agent " + name + " not found\\n")
sys.exit(1)
elif cmd2 == "wait":
@@ -1143,6 +1150,10 @@ elif cmd1 == "agent":
print(buffer_content)
sys.exit(0)
else:
if state.get("strict_agent_names"):
sys.stderr.write(json.dumps({"error": {"code": "agent_not_found",
"message": "agent " + name + " not found"}}) + "\\n")
sys.exit(1)
sys.stderr.write("Agent " + name + " not found\\n")
sys.exit(1)
elif cmd2 == "prompt":
+57 -41
View File
@@ -11,7 +11,7 @@
"kind": "example",
"evidence": [
"multi-agent-mux-create/scripts/create_session.sh:99",
"multi-agent-mux-create/scripts/create_session.sh:356"
"multi-agent-mux-create/scripts/create_session.sh:359"
],
"mismatch": null,
"note": "HERDR_SESSION_NAME environment example"
@@ -61,21 +61,21 @@
{
"skill": "multi-agent-mux-create",
"start": 134,
"end": 152,
"end": 150,
"lang": "yaml",
"sha": "2dd608d04ae0",
"sha": "efba73c1d7f7",
"class": "c",
"kind": "example",
"evidence": [
"multi-agent-mux-create/scripts/create_session.sh:518-545"
"multi-agent-mux-create/scripts/create_session.sh:517-544"
],
"mismatch": null,
"note": "Output format: the row create_session.sh writes"
},
{
"skill": "multi-agent-mux-create",
"start": 158,
"end": 160,
"start": 156,
"end": 158,
"lang": "bash",
"sha": "613003708f19",
"class": "c",
@@ -88,8 +88,8 @@
},
{
"skill": "multi-agent-mux-create",
"start": 204,
"end": 208,
"start": 202,
"end": 206,
"lang": "bash",
"sha": "38836d31bf38",
"class": "c",
@@ -103,8 +103,8 @@
},
{
"skill": "multi-agent-mux-create",
"start": 231,
"end": 253,
"start": 229,
"end": 251,
"lang": "bash",
"sha": "d90cfd2555fd",
"class": "b",
@@ -122,7 +122,7 @@
"class": "c",
"kind": "invocation",
"evidence": [
"multi-agent-mux-delegate-job/multi-agent-mux-delegate-job:1046-1057"
"multi-agent-mux-delegate-job/multi-agent-mux-delegate-job:1051-1062"
],
"mismatch": null,
"note": "CLI reference; subcommands submit/status/list/verify/wait/logs match main()"
@@ -245,7 +245,7 @@
"class": "c",
"kind": "example",
"evidence": [
"multi-agent-mux-monitor/scripts/reconcile.sh:708"
"multi-agent-mux-monitor/scripts/reconcile.sh:705"
],
"mismatch": null,
"note": "drift B example"
@@ -259,7 +259,7 @@
"class": "c",
"kind": "example",
"evidence": [
"multi-agent-mux-monitor/scripts/reconcile.sh:735"
"multi-agent-mux-monitor/scripts/reconcile.sh:732"
],
"mismatch": null,
"note": "drift C example (indented fence)"
@@ -385,7 +385,7 @@
"class": "c",
"kind": "example",
"evidence": [
"multi-agent-mux-status/scripts/status.sh:411"
"multi-agent-mux-status/scripts/status.sh:410"
],
"mismatch": null,
"note": "table output example"
@@ -393,15 +393,15 @@
{
"skill": "multi-agent-mux-status",
"start": 74,
"end": 106,
"end": 105,
"lang": "json",
"sha": "cfea3a10dc8d",
"sha": "8e55751f5e8d",
"class": "c",
"kind": "example",
"evidence": [
"multi-agent-mux-status/scripts/status.sh:31-36",
"multi-agent-mux-status/scripts/status.sh:209-232",
"multi-agent-mux-monitor/scripts/reconcile.sh:816"
"multi-agent-mux-status/scripts/status.sh:209-231",
"multi-agent-mux-monitor/scripts/reconcile.sh:813"
],
"mismatch": [
"S1 (fixed): the example used rows/yaml_entries/unregistered; the real keys are reconcile.sh's drift object plus sessions_detail"
@@ -410,14 +410,14 @@
},
{
"skill": "multi-agent-mux-stop",
"start": 42,
"end": 55,
"start": 41,
"end": 54,
"lang": "bash",
"sha": "301c347ff881",
"class": "c",
"kind": "invocation",
"evidence": [
"multi-agent-mux-stop/scripts/stop_session.sh:1-33"
"multi-agent-mux-stop/scripts/stop_session.sh:1-36"
],
"mismatch": [
"T2 (fixed): the --purge-conversation example lacked --yes and would exit 3"
@@ -426,22 +426,22 @@
},
{
"skill": "multi-agent-mux-stop",
"start": 81,
"end": 85,
"start": 82,
"end": 86,
"lang": "-",
"sha": "a973676cb2d1",
"class": "c",
"kind": "diagram",
"evidence": [
"multi-agent-mux-stop/scripts/stop_session.sh:183-197"
"multi-agent-mux-stop/scripts/stop_session.sh:250-264"
],
"mismatch": null,
"note": "state machine"
},
{
"skill": "multi-agent-mux-stop",
"start": 102,
"end": 104,
"start": 103,
"end": 105,
"lang": "bash",
"sha": "4189d73d6896",
"class": "c",
@@ -454,8 +454,8 @@
},
{
"skill": "multi-agent-mux-stop",
"start": 127,
"end": 145,
"start": 128,
"end": 146,
"lang": "bash",
"sha": "bcaa2a03484e",
"class": "b",
@@ -463,6 +463,20 @@
"evidence": [],
"mismatch": null,
"note": "post-stop checks; no script offers them as a command - stage 3"
},
{
"skill": "multi-agent-mux-update",
"start": 17,
"end": 19,
"lang": "bash",
"sha": "63f73ade58d3",
"class": "c",
"kind": "invocation",
"evidence": [
"multi-agent-mux-update/scripts/update.sh:3"
],
"mismatch": null,
"note": "entry point call (Issue #72)"
}
],
"removed": [
@@ -476,11 +490,11 @@
"evidence": [
"multi-agent-mux-resume/scripts/resume_session.sh:111-130",
"multi-agent-mux-resume/scripts/resume_session.sh:133-144",
"multi-agent-mux-resume/scripts/resume_session.sh:147-158",
"multi-agent-mux-resume/scripts/resume_session.sh:172-197",
"multi-agent-mux-resume/scripts/resume_session.sh:245",
"multi-agent-mux-resume/scripts/resume_session.sh:249",
"multi-agent-mux-resume/scripts/resume_session.sh:255-258",
"multi-agent-mux-resume/scripts/resume_session.sh:147-170",
"multi-agent-mux-resume/scripts/resume_session.sh:188-213",
"multi-agent-mux-resume/scripts/resume_session.sh:265",
"multi-agent-mux-resume/scripts/resume_session.sh:269",
"multi-agent-mux-resume/scripts/resume_session.sh:275-278",
"lib_py/agents/adapters/claude.py:109"
],
"mismatch": [
@@ -503,9 +517,9 @@
"base_sha": "a7ed3e902cae",
"class": "a",
"evidence": [
"multi-agent-mux-stop/scripts/stop_session.sh:98",
"multi-agent-mux-stop/scripts/stop_session.sh:139-142",
"multi-agent-mux-stop/scripts/stop_session.sh:183-187"
"multi-agent-mux-stop/scripts/stop_session.sh:101",
"multi-agent-mux-stop/scripts/stop_session.sh:128-198",
"multi-agent-mux-stop/scripts/stop_session.sh:250-254"
],
"mismatch": [
"T1: the doc offered --force-no-yaml, which no script accepts"
@@ -547,8 +561,8 @@
"multi-agent-mux-create/scripts/create_session.sh:166-168",
"multi-agent-mux-create/scripts/create_session.sh:207",
"multi-agent-mux-create/scripts/create_session.sh:259-274",
"multi-agent-mux-create/scripts/create_session.sh:361-398",
"multi-agent-mux-create/scripts/create_session.sh:410-412"
"multi-agent-mux-create/scripts/create_session.sh:364-401",
"multi-agent-mux-create/scripts/create_session.sh:413-415"
],
"mismatch": [
"C1: an existing session is exit 1 in the doc, exit 3 in the script",
@@ -568,7 +582,7 @@
"base_sha": "f71be0f26fb8",
"class": "a",
"evidence": [
"multi-agent-mux-create/scripts/create_session.sh:491-610"
"multi-agent-mux-create/scripts/create_session.sh:489-607"
],
"mismatch": [],
"replacement": "bash .agents/skills/multi-agent-mux-create/scripts/create_session.sh --workspace <path> --agent <agent> --role <role>",
@@ -596,10 +610,12 @@
"2",
"3",
"4",
"5",
"6",
"other"
],
"passthrough": {
"1": "multi-agent-mux-stop/scripts/stop_session.sh:322"
"1": "multi-agent-mux-stop/scripts/stop_session.sh:389"
}
},
"multi-agent-mux-create/scripts/create_session.sh": {
@@ -617,7 +633,7 @@
"other"
],
"passthrough": {
"4": "multi-agent-mux-create/scripts/create_session.sh:550"
"4": "multi-agent-mux-create/scripts/create_session.sh:549"
}
}
},
+19 -7
View File
@@ -48,7 +48,7 @@ class TestDeployLayout(unittest.TestCase):
self.assertTrue(os.path.exists(os.path.join(self.test_dir, ".agents", "skills", "lib.sh")))
def test_td6_td7_td8_mam_deploy_layout_and_removal(self):
"""T-D6~D8: Verify remove.sh/update.sh installed in .mam_deploy and work properly."""
"""T-D6~D8: Verify remove.sh installed in .mam_deploy (update.sh not, Issue #72) and works properly."""
subprocess.run(
["bash", os.path.join(self.repo_root, "deploy/install.sh"), self.test_dir],
env=self.env,
@@ -59,7 +59,7 @@ class TestDeployLayout(unittest.TestCase):
remover = os.path.join(self.test_dir, ".mam_deploy", "remove.sh")
updater = os.path.join(self.test_dir, ".mam_deploy", "update.sh")
self.assertTrue(os.path.exists(remover))
self.assertTrue(os.path.exists(updater))
self.assertFalse(os.path.exists(updater))
self.assertTrue(os.access(remover, os.X_OK))
# T-D8: Execution from inside .mam_deploy directory targets parent workspace
@@ -129,17 +129,29 @@ class TestDeployLayout(unittest.TestCase):
self.assertTrue(os.path.exists(backups_dir))
def test_td27_td28_update_preserves_mam_state(self):
"""T-D27~D28: Verify update.sh preserves .mam state files across cycles (B-5 gate)."""
"""T-D27~D28: Verify the update skill preserves .mam state files across cycles (B-5 gate)."""
subprocess.run(
["bash", os.path.join(self.repo_root, "deploy/install.sh"), self.test_dir],
env=self.env,
check=True
)
# Run update.sh
updater = os.path.join(self.test_dir, ".mam_deploy", "update.sh")
res = subprocess.run(["bash", updater, "--force"], cwd=self.test_dir, env=self.env, capture_output=True, text=True)
self.assertEqual(res.returncode, 0, f"update.sh failed: {res.stderr}")
# Run the update skill (Issue #72: .mam_deploy/update.sh is gone)
updater = os.path.join(self.test_dir, ".agents", "skills", "multi-agent-mux-update", "scripts", "update.sh")
env = {k: v for k, v in self.env.items() if not k.startswith("HERDR_")
and k not in ("WORKSPACE_ROOT", "AGENT_SESSIONS_YAML", "MAM_JOBS_DIR")}
env["MAM_NO_SUBSCRIBER_DAEMON"] = "1"
# The update lists the live herdr agents; never ask a real server here.
fake = os.path.join(self.temp_dir, "fake-herdr-bin")
os.makedirs(fake)
with open(os.path.join(fake, "herdr"), "w") as f:
f.write('#!/bin/sh\necho \'{"sessions": []}\'\n')
os.chmod(os.path.join(fake, "herdr"), 0o755)
env["PATH"] = fake + ":" + env["PATH"]
res = subprocess.run(["bash", updater, "--yes"], cwd=self.test_dir, env=env, capture_output=True, text=True,
stdin=subprocess.DEVNULL)
self.assertEqual(res.returncode, 0, f"the update skill failed: {res.stdout}{res.stderr}")
self.assertFalse(os.path.exists(os.path.join(self.test_dir, ".mam_deploy", "update.sh")))
# Verify asset_hashes.txt and version.txt still exist
self.assertTrue(os.path.exists(os.path.join(self.test_dir, ".mam", "asset_hashes.txt")))
+1 -1
View File
@@ -581,7 +581,7 @@ def test_t3_1_create_persists_and_forwards_a_foreign_binding(mam_sandbox, mock_h
for k, v in zip(FORWARDED_ENV, list(_binding(tmp).values())[:3]):
assert f"{k}={v}" in flags, flags
assert "WORKSPACE_ROOT=" not in row["pane"]["cmd_full"]
assert "WORKSPACE_ROOT=" not in row["start_command"]
assert "WORKSPACE_ROOT=" not in row["attach_command"]
_assert_no_env_in_agent_args(mock_herdr)
+14 -6
View File
@@ -299,10 +299,11 @@ def test_s11_this_branch_does_not_change_the_generated_shim(mam_sandbox):
true at runtime. Issue #45 changed the heredoc on purpose (the bootstrap
starts the server with restore-on-restart off, and its review R1 passes
REAL_HERDR to the config check), and so did the real-server leak fix
(only a directory that is itself *-shim is skipped), so the base is
that commit."""
(only a directory that is itself *-shim is skipped). Issue #71 changed it
too (one sanitize definition written in with `declare -f`, agent attach
resolved, no silent wrong name), so the base is that commit."""
repo = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
base = os.environ.get("MAM_BASE_SHA", "525df2b")
base = os.environ.get("MAM_BASE_SHA", "0fc162e")
show = subprocess.run(["git", "show", "%s:.agents/skills/lib.sh" % base],
capture_output=True, text=True, cwd=repo)
if show.returncode != 0:
@@ -316,16 +317,21 @@ def test_s11_this_branch_does_not_change_the_generated_shim(mam_sandbox):
def _heredoc_body(text):
"""What lib.sh writes into the shim: the sanitize function it declares in
(Issue #71), then the heredoc."""
lines = text.splitlines(True)
fn = [i for i, line in enumerate(lines) if line.startswith("_sanitize_herdr_agent_name() {")]
assert len(fn) == 1, "one _sanitize_herdr_agent_name definition in lib.sh"
fn_end = next(j for j in range(fn[0], len(lines)) if lines[j].rstrip("\n") == "}")
start = None
for i, line in enumerate(lines):
if line.strip() == "cat <<'EOF' > \"$tmp_file\"":
if line.strip() == "cat <<'EOF'":
start = i + 1
break
assert start is not None, "shim heredoc not found in lib.sh"
for j in range(start, len(lines)):
if lines[j].rstrip("\n") == "EOF":
return "".join(lines[start:j])
return "".join(lines[fn[0]:fn_end + 1] + lines[start:j])
raise AssertionError("unterminated shim heredoc")
@@ -1272,7 +1278,9 @@ def test_s9_a_kill_that_left_the_session_alive_is_reported(mam_sandbox):
"rolled back" anyway, leaving a `running` row against a failed job."""
res, published, yaml_text = _run_rollback(mam_sandbox, kill_rc=1, still_alive=True)
assert res.returncode == 43, res.stdout + res.stderr
assert "test-sess" in res.stderr and "kill-session" in res.stderr
# Issue #71: the clean-up hint is the stop skill, naming the server
assert "Clean it up with: bash .agents/skills/multi-agent-mux-stop/scripts/stop_session.sh --session test-sess --herdr-session default" in res.stderr
assert "kill-session -t" not in res.stderr
assert "rollback-failed" in yaml_text, yaml_text
assert "status: running" in yaml_text, "a session that is still up must not be called gone"
assert "delegate_job_id: null" in yaml_text, yaml_text
+2
View File
@@ -59,6 +59,8 @@ INT_KNOBS = {
# once) and the wrapper's registry wait (0: decide at once - clear or busy)
"MAM_ONBOARD_WAIT_S": (1, "600"),
"MAM_DISPATCH_WAIT_S": (0, "600"),
# Issue #72: how long the update waits for the monitor to stop after SIGTERM
"MAM_UPDATE_MONITOR_STOP_S": (1, "30"),
# delegate_wait_started's ack_s (MAM_ONBOARD_ACK_S / DELEGATE_JOB_ACK_S via create_session.sh)
"delegate_wait_started:ack_s": (0, "180"),
# wrapper flags
+3 -3
View File
@@ -115,8 +115,8 @@ def test_inventory_matches_skill_md(skill):
f"actual (start, end, lang, sha): {json.dumps(actual)}")
def test_inventory_covers_eight_skills():
assert len(SKILL_NAMES) == 8
def test_inventory_covers_nine_skills():
assert len(SKILL_NAMES) == 9 # Issue #72: + multi-agent-mux-update
assert {b["skill"] for b in DATA["blocks"]} == set(SKILL_NAMES)
@@ -217,7 +217,7 @@ def test_literal_exit_rule_on_base_scripts():
"""The counting rule: heredocs and comments skipped, so create's header `exit 4`
and stop's `sys.exit(7)` are not counted."""
assert literal_exit_codes("multi-agent-mux-resume/scripts/resume_session.sh") == {"0", "1", "2"}
assert literal_exit_codes("multi-agent-mux-stop/scripts/stop_session.sh") == {"0", "1", "2", "3", "4"}
assert literal_exit_codes("multi-agent-mux-stop/scripts/stop_session.sh") == {"0", "1", "2", "3", "4", "5", "6"}
assert literal_exit_codes("multi-agent-mux-create/scripts/create_session.sh") == {"0", "1", "2", "3", "42", "43", "44", "45"}
+4 -1
View File
@@ -170,7 +170,10 @@ def test_a1_create_replaces_the_monitors_registration_of_its_own_pane(mam_sandbo
assert f"NOTE: the monitor registered {name} while it was being created" in res.stdout
rows = [r for r in _rows(yaml_path) if r["name"] == name]
assert len(rows) == 1 and rows[0]["status"] == "running" and rows[0]["role"] == "Creator"
assert rows[0].get("last_visible_note") != "auto-registered by monitor" and rows[0].get("start_command")
# create's own row: only create records how the claude id was chosen
# (the monitor's registration and the seeded row have no session_id_source)
assert rows[0].get("last_visible_note") != "auto-registered by monitor"
assert rows[0].get("session_id_source") in ("assigned", "pending-discovery")
@pytest.mark.parametrize("kind", ["real", "old-adoption", "adoption-with-job", "other-server"])
+1 -1
View File
@@ -96,7 +96,7 @@ def test_the_1x_capture_still_reads_empty():
@pytest.mark.parametrize("name", HOME_SCREENS)
def test_stall_watch_sees_the_2x_home_box_empty(name, monkeypatch):
src = stall_watch.HerdrSource("t", "opencode")
src = stall_watch.HerdrSource("t", "opencode", name="t")
monkeypatch.setattr(src, "_herdr", lambda *args: _pane(name))
assert src.input_empty() is True
+5 -3
View File
@@ -1090,7 +1090,9 @@ def test_id_order_dead_owner_marker_blocks_while_its_unregistered_herdr_session_
assert marker_a.exists()
assert ("srv-x", "orphan-a-agy") in probes
err = capsys.readouterr().err
assert "HERDR_SESSION_NAME=srv-x herdr kill-session -t orphan-a-agy" in err
# Issue #71: the hint is a stop-skill command naming the server
assert "bash .agents/skills/multi-agent-mux-stop/scripts/stop_session.sh --session orphan-a-agy --herdr-session srv-x" in err
assert "kill-session" not in err
# No authoritative answer from herdr -> kept and blocks as well
unknown_sessions.add("orphan-a-agy")
@@ -1137,7 +1139,7 @@ def test_id_order_dead_owner_marker_blocks_while_its_unregistered_herdr_session_
assert probes == [("", "same-agy")]
assert json.loads(same.read_text())["owner_pid"] == live.pid
err = capsys.readouterr().err
assert "its herdr session is alive" in err and "kill-session -t same-agy" in err
assert "its herdr session is alive" in err and "bash .agents/skills/multi-agent-mux-stop/scripts/stop_session.sh --session same-agy --herdr-session " in err
unknown_sessions.add("same-agy")
assert wait_same() == 1
assert json.loads(same.read_text())["owner_pid"] == live.pid
@@ -1188,7 +1190,7 @@ def test_id_order_create_waits_for_orphan_herdr_session_of_a_killed_create(
res = create_b()
assert res.returncode == 1, res.stdout + res.stderr
assert name_a in res.stderr and "kill-session -t " + name_a in res.stderr
assert name_a in res.stderr and "bash .agents/skills/multi-agent-mux-stop/scripts/stop_session.sh --session " + name_a + " --herdr-session orphan-ws" in res.stderr
assert "its herdr session is alive" in res.stderr
assert name_b not in _read_rows(mam_sandbox)
assert name_b not in json.loads(Path(mock_herdr).read_text()).get("agents", {})
@@ -0,0 +1,129 @@
"""Issue #66: stall_watch calls herdr with the sanitized agent name.
herdr registers an agent under `sanitize_herdr_agent_name(session)` (lower
case, at most 32 characters). The watcher gets the original session name from
the wrapper, so it must convert it for every herdr call while its logs keep
the original name and the agent kind is still read off the original suffix.
The harness is the one of test_issue30_stall_watch (a real watcher process, a
real job, the conftest mock herdr), with the mock switched to
`strict_agent_names`: like the real herdr it answers an agent only under its
registered (sanitized) name, so the original name gets `agent_not_found`.
"""
from __future__ import annotations
import json
import subprocess
import sys
import time
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent))
import test_issue30_stall_watch as t30 # noqa: E402
from lib_py.agents.sanitize import sanitize_herdr_agent_name # noqa: E402
LONG = "creator-claude-fix-issue-99-long-branch" # 39 characters
UPPER = "Creator-Claude-T"
PLAIN = "creator-claude-t" # needs no conversion
LONG_CODEX = "mam-wt-issue-66-very-long-creator-codex" # kind only in the original suffix
DIALOG = "\n Dangerous rm operation detected. Do you want to proceed?\n 1. Yes\n"
@pytest.fixture
def strict_box(mam_sandbox, mock_herdr):
state = json.loads(mock_herdr.read_text())
state["strict_agent_names"] = True
mock_herdr.write_text(json.dumps(state, indent=2))
reg = mam_sandbox / ".mam" / "jobs"
return mock_herdr, reg, t30._env(mam_sandbox, MAM_STALL_IDLE_S=2)
def _agent_calls(state_file: Path) -> list:
return [c for c in json.loads(state_file.read_text())["calls"]
if c[:1] == ["agent"] and c[1:2] in (["get"], ["read"], ["prompt"], ["wait"])]
def test_the_names_change_and_the_strict_mock_answers_only_the_sanitized_one(strict_box):
state, _, env = strict_box
assert len(LONG) > 32 and sanitize_herdr_agent_name(LONG) != LONG
assert sanitize_herdr_agent_name(UPPER) == PLAIN
assert sanitize_herdr_agent_name(PLAIN) == PLAIN
for name in (LONG, UPPER):
herdr_name = sanitize_herdr_agent_name(name)
t30._agent(state, herdr_name, "claude", 4242, "idle", "Ready")
for cmd in (["get"], ["read"], ["wait", "--until", "idle"], ["prompt"]):
argv = ["herdr", "agent", cmd[0], name] + cmd[1:] + (["hi"] if cmd == ["prompt"] else [])
res = subprocess.run(argv, capture_output=True, text=True, env=env)
assert res.returncode == 1, (argv, res.stdout)
assert json.loads(res.stderr)["error"]["code"] == "agent_not_found", argv
argv[3] = herdr_name
assert subprocess.run(argv, capture_output=True, text=True, env=env).returncode == 0, argv
@pytest.mark.parametrize("target", [LONG, UPPER, PLAIN])
def test_an_idle_agent_is_nudged_through_the_sanitized_name(strict_box, target):
state, reg, env = strict_box
herdr_name = sanitize_herdr_agent_name(target)
jid = t30._job(env, reg)
agent = t30._proc("exec sleep 300")
try:
t30._agent(state, herdr_name, "claude", agent.pid, "idle", t30._pane("claude"))
w = t30._watch(env, reg, jid, target, "claude")
assert t30._until(lambda: t30._prompts(state))
time.sleep(2)
assert t30._details(reg, jid) == ["stalled:idle"]
calls = _agent_calls(state)
assert {c[1] for c in calls} == {"get", "read", "prompt", "wait"}, calls
assert all(c[2] == herdr_name for c in calls), calls
assert len(t30._prompts(state)) == 1
out = t30._finish(env, reg, jid, w)
assert f"agent {target} idle for" in out
finally:
t30._kill(agent)
@pytest.mark.parametrize("target", [LONG, UPPER])
def test_a_blocked_agent_is_reported_with_the_original_name(strict_box, target):
state, reg, env = strict_box
herdr_name = sanitize_herdr_agent_name(target)
jid = t30._job(env, reg)
agent = t30._proc("exec sleep 300")
try:
t30._agent(state, herdr_name, "claude", agent.pid, "blocked", t30._pane("claude"), detection=DIALOG)
w = t30._watch(env, reg, jid, target, "claude")
assert t30._until(lambda: t30._details(reg, jid))
time.sleep(2)
assert t30._details(reg, jid) == ["stalled:blocked Dangerous rm operation detected. Do you want to proceed?"]
calls = _agent_calls(state)
assert calls and all(c[2] == herdr_name for c in calls), calls
assert not t30._prompts(state)
out = t30._finish(env, reg, jid, w)
assert f"agent {target} is blocked" in out
finally:
t30._kill(agent)
def test_the_kind_of_a_long_name_comes_from_its_original_suffix(strict_box):
"""No usable --agent: the kind comes from the session name. Read off the
sanitized name (a hash at the end) it would be unknown, and the nudge would
be withheld as `input_check_unavailable`."""
state, reg, env = strict_box
herdr_name = sanitize_herdr_agent_name(LONG_CODEX)
assert not herdr_name.endswith("-codex")
jid = t30._job(env, reg)
agent = t30._proc("exec sleep 300")
try:
t30._agent(state, herdr_name, "codex", agent.pid, "idle", t30._pane("codex"))
w = t30._watch(env, reg, jid, LONG_CODEX, "")
assert t30._until(lambda: t30._prompts(state))
time.sleep(2)
assert t30._details(reg, jid) == ["stalled:idle"]
assert all(c[2] == herdr_name for c in _agent_calls(state))
t30._finish(env, reg, jid, w)
finally:
t30._kill(agent)
+566
View File
@@ -0,0 +1,566 @@
"""Issue #71: herdr agent names in the registry and the lib.sh shim.
- attach_command is `herdr --session <server> agent attach <herdr name>` from
one builder (create, resume, reconcile); kill_command/start_command are gone.
- the shim's `agent attach` resolves its target like get/read/prompt.
- one bash sanitize rule, equal to the Python one, with no silent wrong name.
- an old registry is migrated by the writer (YAML and DB), with an entry point.
- stop closes a session that has no registry row, only inside this workspace.
- stall_watch works out the herdr name once.
"""
import hashlib
import json
import os
import shutil
import sqlite3
import subprocess
import sys
from pathlib import Path
import pytest
import yaml
REPO = Path(__file__).resolve().parent.parent
SKILLS = REPO / ".agents" / "skills"
sys.path.insert(0, str(SKILLS))
from lib_py.agents.sanitize import sanitize_herdr_agent_name # noqa: E402
LONG = "lab-issue71-a-very-long-branch-name-creator-claude" # > 32 characters
UPPER = "Lab-Upper-creator-claude"
PLAIN = "lab-plain-creator-claude" # needs no conversion
UNI = "\u0130stanbul-creator-claude" # str.lower() differs from tr here
NL = "newline-creator-claude\n" # $(...) would drop the line break
STOP = "bash .agents/skills/multi-agent-mux-stop/scripts/stop_session.sh"
def test_the_names_need_converting_and_the_plain_one_does_not():
assert len(LONG) > 32 and sanitize_herdr_agent_name(LONG) != LONG
assert sanitize_herdr_agent_name(UPPER) == UPPER.lower()
assert sanitize_herdr_agent_name(PLAIN) == PLAIN
@pytest.mark.parametrize("name", [LONG, UPPER, PLAIN])
@pytest.mark.parametrize("server", ["default", "mam-x", None])
def test_attach_command_names_the_server_and_the_herdr_name(name, server):
from lib_py.agents.sanitize import attach_command
want = "herdr --session %s agent attach %s" % (server or "default", sanitize_herdr_agent_name(name))
assert attach_command(server, name) == want
def test_attach_command_quotes_a_server_that_needs_it():
from lib_py.agents.sanitize import attach_command
assert attach_command("my server", PLAIN) == "herdr --session 'my server' agent attach " + PLAIN
# --- writers -----------------------------------------------------------------
def _rows(sandbox):
return yaml.safe_load((sandbox / ".mam" / "agent-sessions.yaml").read_text())["herdr_sessions"]
def _db_rows(sandbox):
conn = sqlite3.connect(str(sandbox / ".mam" / "agent-sessions.db"))
try:
return {r[0]: json.loads(r[1]) for r in conn.execute("SELECT name, data FROM sessions")}
finally:
conn.close()
def _set_mock(mock_herdr, **kw):
state = json.loads(Path(mock_herdr).read_text())
state.update(kw)
Path(mock_herdr).write_text(json.dumps(state))
def _assert_new_form(row, server):
assert row["attach_command"] == "herdr --session %s agent attach %s" % (
server, sanitize_herdr_agent_name(row["name"]))
assert "kill_command" not in row and "start_command" not in row
@pytest.mark.parametrize("name", [LONG, UPPER, PLAIN, UNI, NL])
@pytest.mark.parametrize("server", ["default", "mam-iso71"])
def test_create_writes_the_attach_command(mam_sandbox, mock_herdr, mock_agents, name, server):
_set_mock(mock_herdr, strict_agent_names=True)
script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh"
res = subprocess.run(["bash", str(script), "--workspace", str(mam_sandbox), "--agent", "claude",
"--role", "creator", "--session", name, "--herdr-session", server, "--no-onboard"],
capture_output=True, text=True, cwd=str(mam_sandbox), timeout=300)
assert res.returncode == 0, res.stdout + res.stderr
(row,) = [r for r in _rows(mam_sandbox) if r["name"] == name]
_assert_new_form(row, server)
agents = json.loads(Path(mock_herdr).read_text())["agents"]
assert sanitize_herdr_agent_name(name) in agents, "the attach name is the one herdr registered"
def _seed(sandbox, rows):
lib = sandbox / ".agents" / "skills" / "lib.sh"
mutation = "d['herdr_sessions'] = " + repr(rows) + "\n"
res = subprocess.run(["bash", "-c", 'source "$1" && atomic_dump_yaml "$AGENT_SESSIONS_YAML"', "_", str(lib)],
input=mutation, capture_output=True, text=True)
assert res.returncode == 0, res.stderr
def _resume_update(sandbox, name, server):
script = sandbox / ".agents" / "skills" / "multi-agent-mux-resume" / "scripts" / "update_yaml_resumed.sh"
return subprocess.run(["bash", str(script), "--session", name, "--uuid", "11111111-2222-3333-4444-555555555555",
"--agent", "claude", "--herdr-session", server, "--workspace", str(sandbox)],
capture_output=True, text=True, cwd=str(sandbox), timeout=120)
@pytest.mark.parametrize("name", [LONG, UPPER, PLAIN])
@pytest.mark.parametrize("server", ["default", "mam-iso71"])
@pytest.mark.parametrize("has_row", [True, False])
def test_resume_writes_the_attach_command(mam_sandbox, mock_herdr, mock_agents, name, server, has_row):
if has_row:
_seed(mam_sandbox, [{"name": name, "status": "stopped", "role": "creator", "herdr_session": "old-srv",
"herdr_server": "old-srv", "pane": {"cwd": str(mam_sandbox), "cmd": "claude"}}])
res = _resume_update(mam_sandbox, name, server)
assert res.returncode == 0, res.stdout + res.stderr
(row,) = [r for r in _rows(mam_sandbox) if r["name"] == name]
assert row["status"] == "running"
_assert_new_form(row, server)
@pytest.mark.parametrize("name", [LONG, UPPER, PLAIN])
def test_reconcile_registers_with_the_attach_command(mam_sandbox, mock_herdr, mock_agents, name):
# The pane must exist, or reconcile never reaches the registration.
_set_mock(mock_herdr, agents={name: {
"agent": "claude", "status": "running", "cwd": str(mam_sandbox), "workspace_id": "w1",
"pid": 9999, "pane_id": "w1:p71", "command": "claude", "buffer": "Ready"}})
assert not any(r["name"] == name for r in _rows(mam_sandbox))
script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-monitor" / "scripts" / "reconcile.sh"
res = subprocess.run(["bash", str(script), "--once"], capture_output=True, text=True,
cwd=str(mam_sandbox), timeout=300)
assert res.returncode == 0, res.stdout + res.stderr
rows = [r for r in _rows(mam_sandbox) if r["name"] == name]
assert len(rows) == 1, "reconcile wrote no row: " + res.stdout + res.stderr
assert rows[0]["last_visible_note"] == "auto-registered by monitor"
_assert_new_form(rows[0], rows[0]["herdr_session"])
# --- shim: agent attach --------------------------------------------------------
def _run_lib(sandbox, body, env=None):
lib = sandbox / ".agents" / "skills" / "lib.sh"
return subprocess.run(["bash", "-c", 'source "$1"\n' + body, "_", str(lib)], capture_output=True,
text=True, cwd=str(sandbox), env=dict(os.environ, **(env or {})), timeout=120)
@pytest.mark.parametrize("name", [LONG, UPPER, PLAIN])
def test_wrapper_attach_calls_herdr_with_the_herdr_name(mam_sandbox, mock_herdr, name):
herdr_name = sanitize_herdr_agent_name(name)
_set_mock(mock_herdr, strict_agent_names=True, agents={herdr_name: {
"agent": "claude", "status": "running", "cwd": str(mam_sandbox), "workspace_id": "w1",
"pid": 9999, "pane_id": "w1:p71", "command": "claude", "buffer": "Ready"}})
res = _run_lib(mam_sandbox, 'herdr agent attach "$0" --takeover'.replace("$0", name))
calls = [c for c in json.loads(Path(mock_herdr).read_text())["calls"] if c[:2] == ["agent", "attach"]]
assert calls == [["agent", "attach", herdr_name, "--takeover"]], (calls, res.stderr)
# --- one sanitize rule ----------------------------------------------------------
NAMES = ["", "plain", "Upper-Case", "9lead", "-dash", "has space/and.dot", "x" * 32, "y" * 33,
LONG, LONG.upper(), "creator-claude-fix-issue-99-long-branch", "-n", "-e", "a\\nb",
NL, "a\nb", "\n", "x\r\ny", "Up\nper"]
UNI_NAMES = [UNI, "caf\u00e9-creator-claude", "\u00fc" * 40, "kelvin-\u212a", "\u65e5\u672c-creator-claude",
"\u0130" * 20 + "-creator-claude", "\u00fc\n"]
def test_lib_sh_has_one_definition():
text = (SKILLS / "lib.sh").read_text()
assert text.count("_sanitize_herdr_agent_name() {") == 1
assert "00000000" not in text
assert "lambda s: s.lower()[:32]" not in text
def test_the_generated_shim_carries_the_same_definition(mam_sandbox):
res = _run_lib(mam_sandbox, "declare -f _sanitize_herdr_agent_name")
assert res.returncode == 0, res.stderr
shim = (mam_sandbox / ".mam" / "shim" / "herdr").read_text()
assert shim.startswith("#!/usr/bin/env bash\n")
assert shim.count("_sanitize_herdr_agent_name ()") == 1
assert res.stdout.strip() in shim
def _definition(tmp_path):
"""The function lib.sh defines, as bash prints it (sourced with a tmp workspace)."""
res = subprocess.run(["bash", "-c", 'source "$1" >/dev/null 2>&1; declare -f _sanitize_herdr_agent_name',
"_", str(SKILLS / "lib.sh")], capture_output=True, text=True,
env=dict(os.environ, WORKSPACE_ROOT=str(tmp_path / "defn-ws")))
assert res.returncode == 0 and res.stdout, res.stderr
return res.stdout
def _tools_dir(tmp_path, tools):
d = tmp_path / ("bin-" + "-".join(tools))
d.mkdir()
for t in tools:
src = shutil.which(t)
assert src, t
(d / t).symlink_to(src)
return d
def _sanitize_with_path(defn, path_dir, name):
bash = shutil.which("bash")
return subprocess.run([bash, "--noprofile", "--norc", "-c", defn + '\n_sanitize_herdr_agent_name "$1"', "_", name],
capture_output=True, text=True, env={"PATH": str(path_dir), "LC_ALL": "C"})
@pytest.mark.parametrize("hashers", [["shasum"], ["sha1sum"], ["openssl"], ["python3"]])
def test_each_hasher_alone_matches_python(tmp_path, hashers):
if not shutil.which(hashers[0]):
pytest.skip(hashers[0] + " not installed")
defn = _definition(tmp_path)
d = _tools_dir(tmp_path, ["tr", "sed", "awk", "cut"] + hashers)
for name in NAMES:
res = _sanitize_with_path(defn, d, name)
assert res.returncode == 0, (name, res.stderr)
assert res.stdout == sanitize_herdr_agent_name(name) + "\n", name
@pytest.mark.parametrize("hashers", [["python3"], ["shasum", "python3"]])
def test_a_name_that_is_not_ascii_matches_python(tmp_path, hashers):
if not shutil.which(hashers[0]):
pytest.skip(hashers[0] + " not installed")
defn = _definition(tmp_path)
d = _tools_dir(tmp_path, ["tr", "sed", "awk", "cut"] + hashers)
for name in UNI_NAMES:
res = _sanitize_with_path(defn, d, name)
assert res.returncode == 0, (name, res.stderr)
assert res.stdout == sanitize_herdr_agent_name(name) + "\n", name
def test_a_name_that_is_not_ascii_needs_python3(tmp_path):
defn = _definition(tmp_path)
d = _tools_dir(tmp_path, ["tr", "sed", "awk", "cut"])
for name in UNI_NAMES:
res = _sanitize_with_path(defn, d, name)
assert res.returncode != 0 and res.stdout == "" and "not ASCII" in res.stderr, (name, res.stderr)
def test_no_hasher_at_all_is_an_error_not_a_name(tmp_path):
defn = _definition(tmp_path)
d = _tools_dir(tmp_path, ["tr", "sed", "awk", "cut"])
res = _sanitize_with_path(defn, d, LONG)
assert res.returncode != 0 and res.stdout == "" and "ERROR" in res.stderr
# a short name needs no hash and still works
res = _sanitize_with_path(defn, d, UPPER)
assert res.returncode == 0 and res.stdout == UPPER.lower() + "\n"
def _no_hash_bin(tmp_path):
"""Stubs: shasum/sha1sum/openssl fail; python3 runs, except the name hash."""
d = tmp_path / "nohash"
d.mkdir()
for t in ("shasum", "sha1sum", "openssl"):
(d / t).write_text("#!/bin/sh\nexit 1\n")
(d / t).chmod(0o755)
(d / "python3").write_text('#!/bin/sh\ncase "$*" in *hashlib.sha1*) exit 1 ;; esac\nexec %s "$@"\n'
% shutil.which("python3"))
(d / "python3").chmod(0o755)
return d
def test_spawn_does_not_go_on_without_the_name(mam_sandbox, mock_herdr, tmp_path):
d = _no_hash_bin(tmp_path)
body = 'export PATH="%s:$PATH"\nherdr new-session -d -s "%s" -c "%s" claude' % (d, LONG, mam_sandbox)
res = _run_lib(mam_sandbox, body)
assert res.returncode != 0, res.stdout + res.stderr
assert "cannot make the herdr name" in res.stderr
calls = json.loads(Path(mock_herdr).read_text())["calls"]
assert not [c for c in calls if c[:2] in (["agent", "start"], ["pane", "split"], ["workspace", "create"])], calls
def test_lookups_report_failure_without_the_name(mam_sandbox, mock_herdr, tmp_path):
d = _no_hash_bin(tmp_path)
for verb in ('has-session -t "%s"', 'agent get "%s"', 'kill-session -t "%s"'):
res = _run_lib(mam_sandbox, 'export PATH="%s:$PATH"\nherdr %s' % (d, verb % LONG))
assert res.returncode != 0 and "cannot make the herdr name" in res.stderr, (verb, res.stderr)
# --- registry migration ----------------------------------------------------------
def _old_rows(ws):
def row(name, status, srv):
return {"name": name, "status": status, "role": "creator", "herdr_session": srv, "herdr_server": srv,
"pane": {"index": 0, "pid": 1, "cmd": "claude", "cwd": str(ws)},
"start_command": "HERDR_SESSION_NAME=%s herdr new-session -d -s %s" % (srv, name),
"attach_command": "HERDR_SESSION_NAME=%s herdr agent attach %s" % (srv, name),
"kill_command": "HERDR_SESSION_NAME=%s herdr kill-session -t %s" % (srv, name)}
return [row(LONG, "running", "mam-x"), row(UPPER, "stopped", "default"), row(PLAIN, "terminated", "mam-y")]
def _write_old_registry(ws, with_db):
mam = ws / ".mam"
mam.mkdir(parents=True, exist_ok=True)
top = {"orchestrator_uuids": ["u-1"], "snapshot": {"taken_at": "2026-10-01T00:00:00Z"}}
d = dict(top, herdr_sessions=_old_rows(ws))
(mam / "agent-sessions.yaml").write_text(yaml.safe_dump(d, sort_keys=False))
if with_db:
conn = sqlite3.connect(str(mam / "agent-sessions.db"))
conn.execute("CREATE TABLE state (id INTEGER PRIMARY KEY, data TEXT)")
conn.execute("CREATE TABLE sessions (name TEXT PRIMARY KEY, status TEXT, pane_cwd TEXT, data JSON)")
conn.execute("INSERT INTO state VALUES (1, ?)", (json.dumps(top),))
for s in d["herdr_sessions"]:
conn.execute("INSERT INTO sessions VALUES (?, ?, ?, ?)", (s["name"], s["status"], s["pane"]["cwd"], json.dumps(s)))
conn.commit()
conn.close()
return mam / "agent-sessions.yaml"
def _entry(yaml_path):
return subprocess.run([sys.executable, "-m", "lib_py.registry_format", "--yaml", str(yaml_path)],
capture_output=True, text=True, env=dict(os.environ, PYTHONPATH=str(SKILLS)), timeout=60)
def _state(ws):
conn = sqlite3.connect(str(ws / ".mam" / "agent-sessions.db"))
try:
return json.loads(conn.execute("SELECT data FROM state WHERE id=1").fetchone()[0])
finally:
conn.close()
def test_migrate_is_pure_and_idempotent(tmp_path):
from lib_py import registry_format
old = {"herdr_sessions": _old_rows(tmp_path), "snapshot": {}}
before = json.dumps(old, sort_keys=True)
new, n = registry_format.migrate(old)
assert json.dumps(old, sort_keys=True) == before, "the input is not modified"
assert n == 3 and new[registry_format.FORMAT_KEY] == registry_format.CURRENT == 2
for s in new["herdr_sessions"]:
_assert_new_form(s, s["herdr_session"])
again, n2 = registry_format.migrate(new)
assert n2 == 0 and again == new
assert registry_format.stored_version({}) == 1
@pytest.mark.parametrize("with_db", [True, False])
def test_entry_point_migrates_yaml_and_db_once(tmp_path, with_db):
ws = tmp_path / "ws"
yaml_path = _write_old_registry(ws, with_db)
res = _entry(yaml_path)
assert res.returncode == 0, res.stderr
assert res.stdout == "registry-format: 1 -> 2, rows changed: 3\n"
y = yaml.safe_load(yaml_path.read_text())
db = _db_rows(ws)
assert y["registry_format"] == 2 and _state(ws)["registry_format"] == 2
assert y["orchestrator_uuids"] == ["u-1"] and y["snapshot"]["taken_at"] == "2026-10-01T00:00:00Z"
assert {s["name"]: s for s in y["herdr_sessions"]} == db, "YAML and DB agree"
for s in y["herdr_sessions"]:
_assert_new_form(s, s["herdr_session"])
assert {s["name"]: s["status"] for s in y["herdr_sessions"]} == {LONG: "running", UPPER: "stopped", PLAIN: "terminated"}
text = yaml_path.read_text()
res = _entry(yaml_path)
assert res.returncode == 0, res.stderr
assert res.stdout == "registry-format: 2 -> 2, rows changed: 0\n"
assert yaml_path.read_text() == text and _db_rows(ws) == db
def test_any_write_migrates_and_rewrites_the_yaml(own_workspace_root, tmp_path):
"""A mutation that changes nothing still leaves YAML and DB in the new form (point A)."""
ws = tmp_path / "ws"
yaml_path = _write_old_registry(ws, with_db=True)
res = subprocess.run(["bash", "-c", 'source "$1" && atomic_dump_yaml "$2"', "_", str(SKILLS / "lib.sh"), str(yaml_path)],
input="pass\n", capture_output=True, text=True, timeout=60)
assert res.returncode == 0, res.stderr
y = yaml.safe_load(yaml_path.read_text())
assert y["registry_format"] == 2
assert {s["name"]: s for s in y["herdr_sessions"]} == _db_rows(ws)
for s in y["herdr_sessions"]:
_assert_new_form(s, s["herdr_session"])
@pytest.mark.parametrize("retry", ["entry-point", "any-write"])
def test_a_retry_repairs_the_yaml_a_failed_migration_left_behind(own_workspace_root, tmp_path, monkeypatch, retry):
"""The DB is committed before the YAML is replaced; if that fails, the next run rewrites the YAML."""
from lib_py import registry_format
ws = tmp_path / "ws"
yaml_path = _write_old_registry(ws, with_db=True)
real_replace, failed = os.replace, []
def replace_once(src, dst, *a, **kw):
if os.path.abspath(str(dst)) == str(yaml_path) and not failed:
failed.append(dst)
raise OSError("injected: YAML not replaced")
return real_replace(src, dst, *a, **kw)
monkeypatch.setenv("YAML_PATH", "")
monkeypatch.setenv("AGENT_SESSIONS_MUTATION", "")
monkeypatch.setattr(os, "replace", replace_once)
assert registry_format.main(["--yaml", str(yaml_path)]) == 1 and failed
monkeypatch.setattr(os, "replace", real_replace)
assert _state(ws)["registry_format"] == 2
assert registry_format.stored_version(yaml.safe_load(yaml_path.read_text())) == 1
if retry == "entry-point":
res = _entry(yaml_path)
else:
res = subprocess.run(["bash", "-c", 'source "$1" && atomic_dump_yaml "$2"', "_", str(SKILLS / "lib.sh"), str(yaml_path)],
input="pass\n", capture_output=True, text=True, timeout=60)
assert res.returncode == 0, res.stderr
y = yaml.safe_load(yaml_path.read_text())
assert y["registry_format"] == 2
assert {s["name"]: s for s in y["herdr_sessions"]} == _db_rows(ws), "YAML and DB agree"
for s in y["herdr_sessions"]:
_assert_new_form(s, s["herdr_session"])
def test_a_new_registry_is_written_current(own_workspace_root, tmp_path):
yaml_path = tmp_path / "fresh" / ".mam" / "agent-sessions.yaml"
yaml_path.parent.mkdir(parents=True)
res = subprocess.run(["bash", "-c", 'source "$1" && atomic_dump_yaml "$2"', "_", str(SKILLS / "lib.sh"), str(yaml_path)],
input="d['herdr_sessions'] = []\n", capture_output=True, text=True, timeout=60)
assert res.returncode == 0, res.stderr
assert yaml.safe_load(yaml_path.read_text())["registry_format"] == 2
def test_entry_point_fails_without_a_registry(tmp_path):
res = _entry(tmp_path / "none" / "agent-sessions.yaml")
assert res.returncode != 0 and "error" in res.stderr and res.stdout == ""
# --- stop: a session with no registry row ----------------------------------------
def _registry_bytes(sandbox):
out = {}
for f in ("agent-sessions.yaml", "agent-sessions.db"):
p = sandbox / ".mam" / f
out[f] = hashlib.sha256(p.read_bytes()).hexdigest() if p.exists() else None
return out
def _stop(sandbox, name, *extra, server="default", env=None):
script = sandbox / ".agents" / "skills" / "multi-agent-mux-stop" / "scripts" / "stop_session.sh"
flag = ["--herdr-session", server] if server else []
return subprocess.run(["bash", str(script), "--session", name] + flag + list(extra),
capture_output=True, text=True, cwd=str(sandbox), timeout=120,
env=dict(os.environ, **(env or {})))
def _agent(cwd, pane):
return {"agent": "claude", "status": "running", "cwd": str(cwd), "workspace_id": "w1",
"pid": 9999, "pane_id": pane, "command": "claude", "buffer": "Ready"}
@pytest.fixture
def registry_with_a_row(mam_sandbox):
_seed(mam_sandbox, [{"name": "other-creator-claude", "status": "stopped", "role": "creator",
"pane": {"cwd": str(mam_sandbox), "cmd": "claude"}}])
return _registry_bytes(mam_sandbox)
def test_stop_closes_a_rowless_session_of_this_workspace(mam_sandbox, mock_herdr, registry_with_a_row):
# no agent suffix and no row: there is no agent kind, and none is needed
_set_mock(mock_herdr, agents={"orphan71": _agent(mam_sandbox / "sub", "w1:p71")})
res = _stop(mam_sandbox, "orphan71")
assert res.returncode == 0, res.stdout + res.stderr
assert "orphan71" not in json.loads(Path(mock_herdr).read_text())["agents"]
assert _registry_bytes(mam_sandbox) == registry_with_a_row
def test_stop_refuses_a_rowless_session_of_another_workspace(mam_sandbox, mock_herdr, registry_with_a_row, tmp_path_factory):
elsewhere = tmp_path_factory.mktemp("elsewhere")
_set_mock(mock_herdr, agents={"foreign71-creator-claude": _agent(elsewhere, "w1:p72")})
res = _stop(mam_sandbox, "foreign71-creator-claude")
assert res.returncode == 5, res.stdout + res.stderr
assert "outside this workspace" in res.stderr
assert "foreign71-creator-claude" in json.loads(Path(mock_herdr).read_text())["agents"]
calls = json.loads(Path(mock_herdr).read_text())["calls"]
assert not [c for c in calls if c[:2] == ["pane", "close"]], calls
assert _registry_bytes(mam_sandbox) == registry_with_a_row
def test_stop_reports_a_rowless_session_that_is_not_there(mam_sandbox, mock_herdr, registry_with_a_row):
_set_mock(mock_herdr, strict_agent_names=True)
res = _stop(mam_sandbox, "absent71")
assert res.returncode == 1, res.stdout + res.stderr
assert "has no agent of that name" in res.stderr
assert _registry_bytes(mam_sandbox) == registry_with_a_row
def test_stop_without_a_row_uses_the_environments_server(mam_sandbox, mock_herdr, registry_with_a_row):
"""No --herdr-session: the server is HERDR_SESSION_NAME, as create chose it, not one derived from the workspace."""
_set_mock(mock_herdr, agents={"orphan73": _agent(mam_sandbox, "w1:p74")}, calls=[])
res = _stop(mam_sandbox, "orphan73", server=None, env={"HERDR_SESSION_NAME": "wanted-env-server"})
assert res.returncode == 0, res.stdout + res.stderr
assert "wanted-env-server" in res.stdout
calls = json.loads(Path(mock_herdr).read_text())["calls"]
# every call on an agent or pane names that server (`session list` and `config check` name none)
targeted = [c for c in calls if "agent" in c[:3] or "pane" in c[:3]]
assert targeted and all(c[:2] == ["--session", "wanted-env-server"] for c in targeted), calls
assert ["--session", "wanted-env-server", "agent", "get", "orphan73"] in calls
assert _registry_bytes(mam_sandbox) == registry_with_a_row
def test_stop_does_not_take_an_unreadable_row_for_no_row(mam_sandbox, mock_herdr):
"""Only "no such row" leads to the row-less path; a lookup that fails otherwise stops."""
db = mam_sandbox / ".mam" / "agent-sessions.db"
if db.exists():
db.unlink()
(mam_sandbox / ".mam" / "agent-sessions.yaml").write_text("herdr_sessions:\n- 42\n")
_set_mock(mock_herdr, agents={"orphan74": _agent(mam_sandbox, "w1:p75")})
res = _stop(mam_sandbox, "orphan74")
assert res.returncode == 1, res.stdout + res.stderr
assert "cannot read the row" in res.stderr
assert "orphan74" in json.loads(Path(mock_herdr).read_text())["agents"]
@pytest.mark.parametrize("broken", ["yaml-without-db", "db", "no-sessions-table", "no-tables"])
def test_stop_does_not_take_an_unreadable_registry_for_no_row(mam_sandbox, mock_herdr, broken):
"""load_state_json's {} on a read error is not "no row": a registered session is not closed row-less."""
name = "orphan75-creator-claude"
_seed(mam_sandbox, [{"name": name, "status": "running", "role": "creator", "agent": "claude",
"pane": {"cwd": str(mam_sandbox), "cmd": "claude"}}])
mam = mam_sandbox / ".mam"
if broken == "yaml-without-db":
for f in mam.glob("agent-sessions.db*"):
f.unlink()
(mam / "agent-sessions.yaml").write_text("herdr_sessions: [\n - {name: " + name + "\n")
elif broken == "db":
for f in mam.glob("agent-sessions.db-*"):
f.unlink()
(mam / "agent-sessions.db").write_bytes(b"this is not a database" * 64)
else:
# a missing table is not an empty registry
conn = sqlite3.connect(str(mam / "agent-sessions.db"))
conn.execute("DROP TABLE sessions")
if broken == "no-tables":
conn.execute("DROP TABLE state")
conn.commit()
conn.close()
_set_mock(mock_herdr, agents={name: _agent(mam_sandbox, "w1:p76")})
res = _stop(mam_sandbox, name)
assert res.returncode == 1, res.stdout + res.stderr
assert "cannot read" in res.stderr and "no registry row" not in res.stdout
assert name in json.loads(Path(mock_herdr).read_text())["agents"]
def test_stop_without_a_row_purges_nothing(mam_sandbox, mock_herdr, registry_with_a_row):
_set_mock(mock_herdr, agents={"orphan72": _agent(mam_sandbox, "w1:p73")})
res = _stop(mam_sandbox, "orphan72", "--purge-conversation", "--yes")
assert res.returncode == 1, res.stdout + res.stderr
assert "orphan72" in json.loads(Path(mock_herdr).read_text())["agents"]
assert _registry_bytes(mam_sandbox) == registry_with_a_row
# --- stall_watch -----------------------------------------------------------------
def test_stall_watch_works_out_the_herdr_name_once(monkeypatch):
from lib_py import stall_watch
seen = []
real = stall_watch.sanitize_herdr_agent_name
monkeypatch.setattr(stall_watch, "sanitize_herdr_agent_name", lambda n: seen.append(n) or real(n))
class Args(object):
target, agent, herdr_session, registry_dir, job = LONG, None, "mam-x", "/nonexistent", "j"
sw = stall_watch.StallWatch(Args())
assert seen == [LONG]
assert sw.herdr_name == sw.src.name == sanitize_herdr_agent_name(LONG)
assert sw.src.target == LONG and sw.src.agent == "claude"
+283
View File
@@ -0,0 +1,283 @@
"""Issue #72 (decision 8, U2): while an update runs, the entries that start
something or write the registry refuse - create, resume, run_loop.sh, delegate
submit, stop (plain and --purge-conversation) and orc-onboard - and change
nothing. A marker its update left behind (`stale`) refuses nothing.
The marker is .mam/update-in-progress as the update skill publishes it (pid +
lstart, loop_lock.sh); `live` here is a sleeping helper process's.
"""
from __future__ import annotations
import hashlib
import json
import os
import subprocess
import sys
from pathlib import Path
import pytest
import yaml
UUID = "11111111-2222-3333-4444-555555555555"
ORC = "99999999-8888-7777-6666-555555555555"
ORC_OLD = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
@pytest.fixture
def marker(mam_sandbox):
procs = []
def make(live: bool, root: Path = None):
p = (root or mam_sandbox) / ".mam" / "update-in-progress"
if live:
proc = subprocess.Popen(["sleep", "120"], start_new_session=True)
procs.append(proc)
ls = " ".join(subprocess.run(["ps", "-p", str(proc.pid), "-o", "lstart="],
capture_output=True, text=True).stdout.split())
p.write_text("pid=%d\nlstart=%s\nstarted=x\n" % (proc.pid, ls))
else:
dead = subprocess.Popen(["true"])
dead.wait()
p.write_text("pid=%d\nlstart=Thu Jan 1 00:00:00 1970\nstarted=x\n" % dead.pid)
return p
yield make
for proc in procs:
proc.kill()
proc.wait()
def _seed(sandbox, rows, **top):
lib = sandbox / ".agents" / "skills" / "lib.sh"
mutation = "d['herdr_sessions'] = %r\n" % (rows,) + "".join("d[%r] = %r\n" % kv for kv in top.items())
res = subprocess.run(["bash", "-c", 'source "$1" && atomic_dump_yaml "$AGENT_SESSIONS_YAML"', "_", str(lib)],
input=mutation, capture_output=True, text=True)
assert res.returncode == 0, res.stderr
def _registry(sandbox):
out = {}
for f in ("agent-sessions.yaml", "agent-sessions.db"):
p = sandbox / ".mam" / f
out[f] = hashlib.sha256(p.read_bytes()).hexdigest() if p.exists() else None
return out
def _script(sandbox, rel):
return str(sandbox / ".agents" / "skills" / rel)
def _run(sandbox, argv, env=None, timeout=120):
return subprocess.run(argv, capture_output=True, text=True, cwd=str(sandbox), timeout=timeout,
env=dict(os.environ, **(env or {})), stdin=subprocess.DEVNULL)
def _refused(res, sandbox):
assert res.returncode != 0, res.stdout + res.stderr
assert "an update of %s is in progress" % sandbox in res.stderr, res.stdout + res.stderr
def _row(name, status="running", agent="claude"):
r = {"name": name, "status": status, "role": "creator", "herdr_session": "default", "herdr_server": "default",
"pane": {"cwd": "", "cmd": agent}, "agent": agent}
if agent == "claude":
r["claude_session_id_own"] = UUID
return r
def _agent(cwd, pane):
return {"agent": "claude", "status": "running", "cwd": str(cwd), "workspace_id": "w1",
"pid": 9999, "pane_id": pane, "command": "claude", "buffer": "Ready"}
def _set_mock(state_file, **kw):
st = json.loads(Path(state_file).read_text())
st.update(kw)
Path(state_file).write_text(json.dumps(st))
def test_create_refuses_while_an_update_runs(mam_sandbox, mock_herdr, mock_agents, marker):
marker(True)
before = _registry(mam_sandbox)
res = _run(mam_sandbox, ["bash", _script(mam_sandbox, "multi-agent-mux-create/scripts/create_session.sh"),
"--workspace", str(mam_sandbox), "--agent", "claude", "--role", "creator",
"--session", "gated-create", "--no-onboard"], timeout=300)
_refused(res, mam_sandbox)
assert _registry(mam_sandbox) == before
assert json.loads(Path(mock_herdr).read_text())["agents"] == {}, "nothing was spawned"
def test_resume_refuses_on_the_spawn_and_the_alive_path(mam_sandbox, mock_herdr, mock_agents, marker):
_seed(mam_sandbox, [dict(_row("gated-agy", status="stopped", agent="agy"), pane={"cwd": str(mam_sandbox), "cmd": "agy"})])
marker(True)
before = _registry(mam_sandbox)
script = _script(mam_sandbox, "multi-agent-mux-resume/scripts/resume_session.sh")
res = _run(mam_sandbox, ["bash", script, "--workspace", str(mam_sandbox), "--agent", "agy",
"--session", "gated-agy"], timeout=300)
_refused(res, mam_sandbox)
assert _registry(mam_sandbox) == before
assert json.loads(Path(mock_herdr).read_text())["agents"] == {}, "nothing was spawned"
_set_mock(mock_herdr, agents={"gated-agy": _agent(mam_sandbox, "w1:p9")})
res = _run(mam_sandbox, ["bash", script, "--workspace", str(mam_sandbox), "--agent", "agy",
"--session", "gated-agy"], timeout=300)
_refused(res, mam_sandbox)
assert _registry(mam_sandbox) == before
def test_run_loop_refuses_before_its_loop_marker(mam_sandbox, marker):
marker(True)
loop_marker = mam_sandbox / ".mam" / "loop-guard-active"
res = _run(mam_sandbox, ["bash", _script(mam_sandbox, "multi-agent-mux-loop/scripts/run_loop.sh"),
"--creator", "c", "--task", "t"],
env={"MAM_LOOP_NO_FREEZE": "1", "MAM_REAL_ROOT": str(mam_sandbox)})
_refused(res, mam_sandbox)
assert not loop_marker.exists()
def test_delegate_submit_registers_no_job(mam_sandbox, mock_herdr, marker):
marker(True)
res = _run(mam_sandbox, ["bash", _script(mam_sandbox, "multi-agent-mux-delegate-job/multi-agent-mux-delegate-job"),
"submit", "--agent", "claude-code", "--agent-session", "herdr:anyone", "--prompt", "hi"])
_refused(res, mam_sandbox)
jobs = mam_sandbox / ".mam" / "jobs"
assert not list(jobs.glob("*.json")) if jobs.exists() else True
@pytest.fixture
def other_ws(tmp_path):
"""A second installed workspace B (update.sh's own test: .mam_deploy/remove.sh),
outside the sandbox (A)."""
b = Path(os.path.realpath(tmp_path)) / "ws-b"
(b / ".mam").mkdir(parents=True)
(b / ".mam_deploy").mkdir()
(b / ".mam_deploy" / "remove.sh").write_text("#!/bin/sh\n")
(b / "sub").mkdir()
return b
def _submit_to(sandbox, session, env=None):
return _run(sandbox, ["bash", _script(sandbox, "multi-agent-mux-delegate-job/multi-agent-mux-delegate-job"),
"submit", "--agent", "claude-code", "--agent-session", session, "--prompt", "hi"], env=env)
def _no_job(sandbox):
jobs = sandbox / ".mam" / "jobs"
return not jobs.exists() or not list(jobs.glob("*.json"))
def _server_for(sandbox, name):
"""The server the wrapper delivers a rowless target on (resolve_herdr_session)."""
res = subprocess.run(["bash", "-c", 'source "$1" && resolve_herdr_session "$2" "$3"', "_",
str(sandbox / ".agents" / "skills" / "lib.sh"), name, str(sandbox)],
capture_output=True, text=True, cwd=str(sandbox))
assert res.returncode == 0 and res.stdout.strip(), res.stderr
return res.stdout.strip()
def _target_in(kind, sandbox, mock_herdr, b):
"""Put the delegate target `tgt` in workspace B as <kind>; the env for submit."""
if kind == "rowless-live":
_set_mock(mock_herdr, agents={"tgt": _agent(b / "sub", "w1:p6")})
return {"MAM_SESS": _server_for(sandbox, "tgt")}
r = _row("tgt")
if kind == "row-pane-cwd":
r["pane"]["cwd"] = str(b / "sub")
elif kind == "row-control-plane":
r["pane"]["cwd"] = "/nonexistent/tgt"
r["control_plane"] = {"workspace_root": str(b)}
elif kind == "row-under-incidental-mam":
(b / "sub" / ".mam").mkdir() # runtime state only: not an installed tree
r["pane"]["cwd"] = str(b / "sub")
r["control_plane"] = {"workspace_root": str(b)}
_seed(sandbox, [r])
return {}
KINDS = ["row-pane-cwd", "row-control-plane", "row-under-incidental-mam", "rowless-live"]
@pytest.mark.parametrize("kind", KINDS)
def test_delegate_submit_takes_the_target_workspace_marker(mam_sandbox, mock_herdr, marker, other_ws, kind):
"""R1 (rounds 1, 2): every installed workspace the target is in - by its row,
or for a rowless target by the live agent's cwd - is checked, not only the
caller's; an incidental .mam below B does not hide B."""
env = _target_in(kind, mam_sandbox, mock_herdr, other_ws)
marker(True, other_ws)
res = _submit_to(mam_sandbox, "herdr:tgt", env=env)
assert res.returncode != 0, res.stdout + res.stderr
assert "an update of %s is in progress" % other_ws in res.stderr, res.stdout + res.stderr
assert "registered job" not in res.stdout
assert _no_job(mam_sandbox)
@pytest.mark.parametrize("kind", KINDS)
def test_delegate_submit_waits_for_the_target_workspace_admission_lock(mam_sandbox, mock_herdr, other_ws, kind):
env = _target_in(kind, mam_sandbox, mock_herdr, other_ws)
holder = subprocess.Popen([sys.executable, "-c", "import fcntl, sys, time\n"
"f = open(sys.argv[1], 'a'); fcntl.flock(f, fcntl.LOCK_EX)\n"
"print('locked', flush=True); time.sleep(120)",
str(other_ws / ".mam" / "update-admission.lock")],
stdout=subprocess.PIPE, text=True, start_new_session=True)
try:
assert holder.stdout.readline().strip() == "locked"
res = _submit_to(mam_sandbox, "herdr:tgt", env=dict(env, MAM_UPDATE_ADMISSION_WAIT_S="1"))
finally:
holder.kill()
holder.wait()
assert res.returncode != 0, res.stdout + res.stderr
assert "an update of %s holds its admission lock" % other_ws in res.stderr, res.stdout + res.stderr
assert _no_job(mam_sandbox)
@pytest.mark.parametrize("purge", [False, True])
def test_stop_refuses_and_changes_nothing(mam_sandbox, mock_herdr, marker, purge):
_seed(mam_sandbox, [_row("gated-stop")])
_set_mock(mock_herdr, agents={"gated-stop": _agent(mam_sandbox, "w1:p8")})
marker(True)
before = _registry(mam_sandbox)
extra = ["--purge-conversation", "--yes"] if purge else []
res = _run(mam_sandbox, ["bash", _script(mam_sandbox, "multi-agent-mux-stop/scripts/stop_session.sh"),
"--session", "gated-stop", "--agent", "claude", *extra])
_refused(res, mam_sandbox)
assert _registry(mam_sandbox) == before
assert "gated-stop" in json.loads(Path(mock_herdr).read_text())["agents"], "the session was not touched"
assert not (mam_sandbox / ".mam" / "purging-gated-stop").exists()
def test_orc_onboard_refuses_and_changes_nothing(mam_sandbox, marker):
_seed(mam_sandbox, [], orchestrator_uuids=[ORC_OLD])
marker(True)
before = _registry(mam_sandbox)
script = _script(mam_sandbox, "multi-agent-mux-orc-onboard/scripts/orc_onboard.sh")
for args in (["--uuid", ORC], ["--remove", ORC_OLD]):
res = _run(mam_sandbox, ["bash", script, *args])
_refused(res, mam_sandbox)
assert _registry(mam_sandbox) == before
def test_a_stale_marker_refuses_nothing(mam_sandbox, mock_herdr, marker):
_seed(mam_sandbox, [_row("stale-stop")], orchestrator_uuids=[])
_set_mock(mock_herdr, agents={"stale-stop": _agent(mam_sandbox, "w1:p7")})
marker(False)
res = _run(mam_sandbox, ["bash", _script(mam_sandbox, "multi-agent-mux-orc-onboard/scripts/orc_onboard.sh"),
"--uuid", ORC])
assert res.returncode == 0, res.stdout + res.stderr
assert "stale update marker" in res.stderr
res = _run(mam_sandbox, ["bash", _script(mam_sandbox, "multi-agent-mux-stop/scripts/stop_session.sh"),
"--session", "stale-stop", "--agent", "claude"])
assert res.returncode == 0, res.stdout + res.stderr
d = yaml.safe_load((mam_sandbox / ".mam" / "agent-sessions.yaml").read_text())
assert d["orchestrator_uuids"] == [ORC]
assert {r["name"]: r["status"] for r in d["herdr_sessions"]} == {"stale-stop": "stopped"}
def test_the_admission_lock_is_released_after_the_record(mam_sandbox, marker):
"""An entry takes the lock shared and drops it once its record is written:
a later exclusive taker (the update) gets it at once."""
lib = mam_sandbox / ".agents" / "skills" / "lib.sh"
res = subprocess.run(["bash", "-c", 'source "$1" && mam_update_admit && mam_update_admit_release && '
'exec 7>>"$WORKSPACE_ROOT/.mam/update-admission.lock" && '
'MAM_UPDATE_ADMISSION_WAIT_S=1 python3 -m lib_py.pane_lock acquire --fd 7 --wait-env MAM_UPDATE_ADMISSION_WAIT_S',
"_", str(lib)], capture_output=True, text=True, timeout=60)
assert res.returncode == 0 and res.stdout.strip() == "locked", res.stdout + res.stderr
+184
View File
@@ -0,0 +1,184 @@
"""Issue #72: the attach hints create, stop and the delegate-job wrapper print
are the session row's attach_command (Issue #71); with no row, the one
lib_py.agents.sanitize.attach_command builds for the server the script used.
"""
import json
import os
import re
import subprocess
import sys
import time
from pathlib import Path
import pytest
import yaml
REPO = Path(__file__).resolve().parent.parent
SKILLS = REPO / ".agents" / "skills"
sys.path.insert(0, str(SKILLS))
from lib_py.agents.sanitize import attach_command, sanitize_herdr_agent_name # noqa: E402
LONG = "lab-issue72-a-very-long-branch-name-creator-claude" # > 32 characters
UPPER = "Lab-Upper72-creator-claude"
def _set_mock(mock_herdr, **kw):
state = json.loads(Path(mock_herdr).read_text())
state.update(kw)
Path(mock_herdr).write_text(json.dumps(state))
def _rows(sandbox):
return yaml.safe_load((sandbox / ".mam" / "agent-sessions.yaml").read_text())["herdr_sessions"]
def _seed(sandbox, rows):
lib = sandbox / ".agents" / "skills" / "lib.sh"
mutation = "d['herdr_sessions'] = " + repr(rows) + "\n"
res = subprocess.run(["bash", "-c", 'source "$1" && atomic_dump_yaml "$AGENT_SESSIONS_YAML"', "_", str(lib)],
input=mutation, capture_output=True, text=True)
assert res.returncode == 0, res.stderr
def _row(name, server, cwd, pid=1):
return {"name": name, "status": "running", "role": "creator", "agent": "claude",
"herdr_session": server, "herdr_server": server, "attach_command": attach_command(server, name),
"pane": {"index": 0, "pid": pid, "cmd": "claude", "cwd": str(cwd)}}
def _seeded_attach(sandbox, name, server):
(row,) = [r for r in _rows(sandbox) if r["name"] == name]
assert row["attach_command"] == attach_command(server, name)
return row["attach_command"]
def _agent(cwd, pane, pid=9999):
return {"agent": "claude", "status": "running", "agent_status": "idle", "cwd": str(cwd), "workspace_id": "w1",
"pid": pid, "pane_id": pane, "command": "claude", "buffer": "Ready"}
# --- create ---------------------------------------------------------------------
@pytest.mark.parametrize("name", [LONG, UPPER])
def test_create_prints_the_rows_attach_command(mam_sandbox, mock_herdr, mock_agents, name):
assert sanitize_herdr_agent_name(name) != name, "the herdr name differs from the session name"
_set_mock(mock_herdr, strict_agent_names=True)
script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh"
res = subprocess.run(["bash", str(script), "--workspace", str(mam_sandbox), "--agent", "claude",
"--role", "creator", "--session", name, "--herdr-session", "mam-iso72", "--no-onboard"],
capture_output=True, text=True, cwd=str(mam_sandbox), timeout=300,
env=dict(os.environ, MAM_NO_SUBSCRIBER_DAEMON="1"))
assert res.returncode == 0, res.stdout + res.stderr
(row,) = [r for r in _rows(mam_sandbox) if r["name"] == name]
hints = [l for l in res.stdout.splitlines() if l.startswith("Attach:")]
assert hints == ["Attach: " + row["attach_command"]], res.stdout
assert row["attach_command"] == attach_command("mam-iso72", name)
# --- stop: the stuck-TUI message ------------------------------------------------
def _undying_herdr(mam_sandbox):
"""The mock herdr behind a wrapper that ignores every way to close a pane,
so the session survives stop's kill chain."""
bin_dir = mam_sandbox / "bin"
(bin_dir / "herdr").rename(bin_dir / "herdr-mock72")
(bin_dir / "herdr").write_text(
'#!/usr/bin/env bash\nfor a in "$@"; do case "$a" in kill-session|close) exit 0 ;; esac; done\n'
'exec "%s" "$@"\n' % (bin_dir / "herdr-mock72"))
(bin_dir / "herdr").chmod(0o755)
@pytest.mark.parametrize("name", [LONG, UPPER])
def test_stop_stuck_tui_message_shows_the_rows_attach_command(mam_sandbox, mock_herdr, mock_agents, name):
# The SIGKILL fallback kills the pane pid: make it a child of this test.
pane = subprocess.Popen(["sleep", "300"])
try:
# The row's server is not the one passed on the command line: the row wins.
_seed(mam_sandbox, [_row(name, "mam-row72", mam_sandbox, pid=pane.pid)])
want = _seeded_attach(mam_sandbox, name, "mam-row72")
_set_mock(mock_herdr, strict_agent_names=True,
agents={sanitize_herdr_agent_name(name): _agent(mam_sandbox, "w1:p72", pid=pane.pid)})
_undying_herdr(mam_sandbox)
script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-stop" / "scripts" / "stop_session.sh"
res = subprocess.run(["bash", str(script), "--session", name, "--agent", "claude",
"--herdr-session", "mam-arg72", "--purge-conversation", "--yes"],
capture_output=True, text=True, cwd=str(mam_sandbox), timeout=300,
env=dict(os.environ, SKS_IDLE_TIMEOUT_MS="500"))
finally:
pane.kill()
pane.wait()
assert res.returncode == 4, res.stdout + res.stderr
hints = [l for l in res.stderr.splitlines() if "Diagnose the stuck TUI" in l]
assert hints == [" Diagnose the stuck TUI (%s), then re-run" % want], res.stderr
# --- delegate-job wrapper ---------------------------------------------------------
def _delegate(mam_sandbox, mock_herdr, name):
"""Submit a job to herdr:<name>, acknowledge it as the agent would, and
return (stdout, the herdr server the wrapper prompted)."""
reg_dir = mam_sandbox / ".mam" / "jobs"
reg_dir.mkdir(parents=True, exist_ok=True)
wrapper = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-delegate-job" / "multi-agent-mux-delegate-job"
publish = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-delegate-job" / "scripts" / "publish_event.py"
env = dict(os.environ, DELEGATE_JOB_PYTHON=sys.executable, MQTT_HOST="127.0.0.1", MQTT_PORT="65534",
SKS_IDLE_TIMEOUT_MS="500", MAM_INJECT_ACK_GRACE_S="20")
proc = subprocess.Popen(["bash", str(wrapper), "submit", "--agent", "claude-code",
"--agent-session", "herdr:" + name, "--prompt", "issue 72 attach hint",
"--registry-dir", str(reg_dir), "--workdir", str(mam_sandbox),
"--timeout", "6", "--idle-timeout", "2"],
cwd=str(mam_sandbox), env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
try:
tok, jid = "", ""
deadline = time.time() + 60
while time.time() < deadline and not tok:
for f in reg_dir.glob("*.json"):
try:
tok, jid = json.loads(f.read_text()).get("dispatch_token") or "", f.stem
except ValueError:
pass
time.sleep(0.2)
assert tok, "the job was not dispatched"
ack = subprocess.run([sys.executable, str(publish), "--registry-dir", str(reg_dir), "--job", jid,
"--event", "started", "--expect-dispatch-token", tok, "--attempt-id", "1"],
capture_output=True, text=True, cwd=str(mam_sandbox), env=env, timeout=60)
# publish_event records `started` locally before its (here unreachable) broker publish
assert '"started"' in (reg_dir / (jid + ".events.log")).read_text(), ack.stderr
stdout, stderr = proc.communicate(timeout=180)
finally:
if proc.poll() is None:
proc.kill()
proc.communicate()
calls = json.loads(Path(mock_herdr).read_text())["calls"]
prompts = [c for c in calls if "agent" in c[:3] and "prompt" in c[:4]]
assert prompts, (calls, stdout, stderr)
servers = {c[1] if c[0] == "--session" else "default" for c in prompts}
assert len(servers) == 1, prompts
return stdout + stderr, servers.pop()
def _delegate_hint(out):
hints = [m.group(1) for m in (re.search(r"\(연결하려면: (.*)\)$", l) for l in out.splitlines()) if m]
assert len(hints) == 1, out
return hints[0]
@pytest.mark.parametrize("name", [LONG, UPPER])
def test_delegate_prints_the_rows_attach_command(mam_sandbox, mock_herdr, mock_agents, name):
_seed(mam_sandbox, [_row(name, "mam-row72", mam_sandbox)])
want = _seeded_attach(mam_sandbox, name, "mam-row72")
_set_mock(mock_herdr, strict_agent_names=True,
agents={sanitize_herdr_agent_name(name): _agent(mam_sandbox, "w1:p72")})
out, server = _delegate(mam_sandbox, mock_herdr, name)
assert server == "mam-row72"
assert _delegate_hint(out) == want
@pytest.mark.parametrize("name", [LONG, UPPER])
def test_delegate_without_a_row_builds_it_for_the_server_it_used(mam_sandbox, mock_herdr, mock_agents, name):
assert not any(r["name"] == name for r in _rows(mam_sandbox))
_set_mock(mock_herdr, strict_agent_names=True,
agents={sanitize_herdr_agent_name(name): _agent(mam_sandbox, "w1:p72")})
out, server = _delegate(mam_sandbox, mock_herdr, name)
assert _delegate_hint(out) == attach_command(server, name)
+206
View File
@@ -0,0 +1,206 @@
#!/usr/bin/env python3
"""Issue #72 — the installer side of the multi-agent-mux-update skill.
The update skill runs the new install.sh in place over an existing install
(no remove.sh, .mam kept). These pin what that in-place run must do:
install_state keeps one line per key, no updater is shipped any more and an
old one is cleaned up, and framework files the new version no longer ships
are deleted unless the user modified them.
"""
from __future__ import annotations
import os
import shutil
import subprocess
import tempfile
import pytest
REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
# Extra framework-owned files only the "old" source ships.
RETIRED_SKILL = ".agents/skills/multi-agent-mux-retired72"
RETIRED_UNMODIFIED = [RETIRED_SKILL + "/SKILL.md", RETIRED_SKILL + "/scripts/old.sh"]
RETIRED_SHARED_DIR = ".agents/skills/multi-agent-mux-retired72b"
RETIRED_SHARED = RETIRED_SHARED_DIR + "/SKILL.md"
RETIRED_MODIFIED = ".agents/hooks/retired72_hook.sh"
def _copy_tree(dest):
shutil.copytree(REPO_ROOT, dest,
ignore=shutil.ignore_patterns(".git", ".venv", ".mam", ".mam_deploy",
"__pycache__", ".pytest_cache"),
symlinks=True)
@pytest.fixture(scope="module")
def sources():
"""(new, old): the tree as is, and the same tree plus files it no longer ships."""
tmp = tempfile.mkdtemp(prefix="mam_issue72_src_")
new, old = os.path.join(tmp, "new"), os.path.join(tmp, "old")
_copy_tree(new)
_copy_tree(old)
for rel in RETIRED_UNMODIFIED + [RETIRED_SHARED, RETIRED_MODIFIED]:
path = os.path.join(old, rel)
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w") as f:
f.write("# shipped by the old version only: %s\n" % rel)
try:
yield new, old
finally:
shutil.rmtree(tmp, ignore_errors=True)
@pytest.fixture
def ws():
tmp = tempfile.mkdtemp(prefix="mam_issue72_ws_")
try:
yield os.path.join(tmp, "workspace")
finally:
shutil.rmtree(tmp, ignore_errors=True)
def _install(src, ws):
env = dict(os.environ)
env.update({"MAM_REPO_URL": src, "MAM_SKIP_VENV": "1"})
res = subprocess.run(["bash", os.path.join(src, "deploy", "install.sh"), ws],
env=env, capture_output=True, text=True, timeout=600)
assert res.returncode == 0, res.stdout + res.stderr
return res
def _remove(ws):
res = subprocess.run(["bash", os.path.join(ws, ".mam_deploy", "remove.sh"), "--force"],
cwd=ws, capture_output=True, text=True, timeout=600)
assert res.returncode == 0, res.stdout + res.stderr
return res
def _read(ws, rel):
with open(os.path.join(ws, rel)) as f:
return f.read()
def _manifest(ws):
return _read(ws, ".mam/install_manifest.txt").splitlines()
def _hashed_paths(ws):
return [line.split(None, 1)[1] for line in _read(ws, ".mam/asset_hashes.txt").splitlines()
if line.strip()]
def test_in_place_reinstall_keeps_one_line_per_install_state_key(sources, ws):
new, _ = sources
_install(new, ws)
with open(os.path.join(ws, ".mam", "install_state"), "w") as f:
f.write("first=a\ngitignore_created=1\nlast=b\n")
_install(new, ws)
assert _read(ws, ".mam/install_state").splitlines() == [
"first=a", "gitignore_created=1", "last=b"]
# remove.sh must read the key as 1: the .gitignore it created goes away.
_remove(ws)
assert not os.path.exists(os.path.join(ws, ".gitignore"))
def test_fresh_install_ships_no_updater(sources, ws):
new, _ = sources
res = _install(new, ws)
assert not os.path.exists(os.path.join(ws, ".mam_deploy", "update.sh"))
manifest = _manifest(ws)
assert ".mam_deploy/update.sh" not in manifest and "update.sh" not in manifest
assert "bash .mam_deploy/update.sh" not in res.stdout
assert "multi-agent-mux-update/scripts/update.sh" in res.stdout
def test_in_place_reinstall_deletes_an_old_mam_deploy_updater(sources, ws):
new, _ = sources
_install(new, ws)
with open(os.path.join(ws, ".mam_deploy", "update.sh"), "w") as f:
f.write("#!/usr/bin/env bash\n# old updater\n")
with open(os.path.join(ws, ".mam", "install_manifest.txt"), "a") as f:
f.write(".mam_deploy/update.sh\n")
_install(new, ws)
assert not os.path.exists(os.path.join(ws, ".mam_deploy", "update.sh"))
assert ".mam_deploy/update.sh" not in _manifest(ws)
def test_legacy_root_updater_goes_only_when_the_manifest_lists_it(sources, ws):
new, _ = sources
_install(new, ws)
root_updater = os.path.join(ws, "update.sh")
# Listed: the installer put it there (very old layout).
with open(root_updater, "w") as f:
f.write("# legacy updater\n")
with open(os.path.join(ws, ".mam", "install_manifest.txt"), "a") as f:
f.write("update.sh\n")
_install(new, ws)
assert not os.path.exists(root_updater)
assert not os.path.exists(os.path.join(ws, ".mam_deploy", "update.sh"))
manifest = _manifest(ws)
assert "update.sh" not in manifest and ".mam_deploy/update.sh" not in manifest
# Not listed: the user's own file stays untouched.
with open(root_updater, "w") as f:
f.write("# user's own script\n")
_install(new, ws)
assert _read(ws, "update.sh") == "# user's own script\n"
def test_files_the_new_version_no_longer_ships(sources, ws):
new, old = sources
_install(old, ws)
for rel in RETIRED_UNMODIFIED + [RETIRED_SHARED, RETIRED_MODIFIED]:
assert rel in _manifest(ws) and rel in _hashed_paths(ws), rel
# The user edits one retired file and adds their own file next to another.
with open(os.path.join(ws, RETIRED_MODIFIED), "a") as f:
f.write("# my change\n")
with open(os.path.join(ws, RETIRED_SHARED_DIR, "notes.txt"), "w") as f:
f.write("mine\n")
# A base/ copy of a retired file must go with it.
base_copy = os.path.join(ws, ".mam", "base", RETIRED_UNMODIFIED[0])
os.makedirs(os.path.dirname(base_copy))
shutil.copy(os.path.join(ws, RETIRED_UNMODIFIED[0]), base_copy)
res = _install(new, ws)
for rel in RETIRED_UNMODIFIED + [RETIRED_SHARED]:
assert not os.path.exists(os.path.join(ws, rel)), rel
assert not os.path.exists(os.path.join(ws, RETIRED_SKILL)), "emptied directory left behind"
assert _read(ws, RETIRED_SHARED_DIR + "/notes.txt") == "mine\n"
assert "# my change" in _read(ws, RETIRED_MODIFIED)
assert "Local modification detected" in res.stderr and RETIRED_MODIFIED in res.stderr
for rel in RETIRED_UNMODIFIED:
assert rel not in res.stderr, rel
assert not os.path.exists(base_copy)
manifest, hashed = _manifest(ws), _hashed_paths(ws)
for rel in RETIRED_UNMODIFIED + [RETIRED_SHARED, RETIRED_MODIFIED]:
assert rel not in manifest and rel not in hashed, rel
# Still-shipped framework files are untouched by the cleanup.
assert ".agents/skills/lib.sh" in manifest and ".agents/skills/lib.sh" in hashed
# The kept file is the user's now: uninstalling leaves it in place.
_remove(ws)
assert "# my change" in _read(ws, RETIRED_MODIFIED)
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0,
reason="root reads a mode-000 file")
def test_local_source_update_backup_is_not_staged(sources, ws):
new, _ = sources
backup = os.path.join(new, ".mam", "update-backup")
os.makedirs(backup)
unreadable = os.path.join(backup, "unreadable")
with open(unreadable, "w") as f:
f.write("x")
os.chmod(unreadable, 0)
try:
_install(new, ws)
finally:
os.chmod(unreadable, 0o600)
shutil.rmtree(os.path.join(new, ".mam"))
+183
View File
@@ -0,0 +1,183 @@
"""Issue #72 (decision 2): resume ensures the monitor daemon, as create does.
Only create_session.sh started the per-workspace subscriber daemon, so a
session brought back by resume_session.sh had no monitor unless one already
ran. Resume now calls the same `start_watchdog` with the control root as the
workdir (Issue #21 A8: never a worktree --workspace's copy), on the spawn path
and the already-running path, after the row is written.
What is real: the shipped create/stop/resume scripts and the real
start_watchdog -> lib_py ensure-daemon, against the mock herdr in a sandbox.
What is not: the sandbox's monitor script is a stub that records how it was
started and sleeps, so the daemon's `pgrep -f "bash <path> --subscribe"`
check sees it but nothing reconciles. Every stub is killed in teardown.
"""
import os
import signal
import subprocess
import time
import pytest
CREATE = "multi-agent-mux-create/scripts/create_session.sh"
STOP = "multi-agent-mux-stop/scripts/stop_session.sh"
RESUME = "multi-agent-mux-resume/scripts/resume_session.sh"
MONITOR_REL = os.path.join(".agents", "skills", "multi-agent-mux-monitor", "scripts", "reconcile.sh")
def _script(root, rel):
return str(root / ".agents" / "skills" / rel)
def _install_stub_monitor(base, log):
"""Replace <base>'s monitor with a stub that writes `pid|$0|cwd|args`."""
path = base / MONITOR_REL
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text('#!/bin/bash\nprintf \'%%s|%%s|%%s|%%s\\n\' "$$" "$0" "$PWD" "$*" >> "%s"\n'
'sleep 60 &\nwait $!\n' % log)
path.chmod(0o755)
return str(path)
def _starts(log):
return [line.split("|") for line in log.read_text().splitlines()] if log.exists() else []
def _wait_starts(log, n, timeout=10.0):
deadline = time.time() + timeout
while time.time() < deadline and len(_starts(log)) < n:
time.sleep(0.1)
return _starts(log)
@pytest.fixture
def stub(mam_sandbox):
"""The control root's stub monitor; kills every stub process it recorded
(the mam_sandbox reaper also sweeps anything under the sandbox)."""
logs = []
def install(base, name):
log = mam_sandbox / ("monitor-starts-%s.log" % name)
logs.append(log)
return _install_stub_monitor(base, log), log
yield install
for log in logs:
for row in _starts(log):
pid = int(row[0])
for target in subprocess.run(["pgrep", "-P", str(pid)], capture_output=True,
text=True).stdout.split() + [str(pid)]:
try:
os.kill(int(target), signal.SIGKILL)
except OSError:
pass
def _create(root, ws, name, stop):
subprocess.run(["bash", _script(root, CREATE), "--workspace", ws, "--agent", "claude",
"--role", "creator", "--session", name, "--no-onboard"],
check=True, cwd=str(root), capture_output=True, text=True)
if stop:
subprocess.run(["bash", _script(root, STOP), "--session", name, "--agent", "claude"],
check=True, cwd=str(root), capture_output=True, text=True)
def _resume(root, ws, name):
return subprocess.run(["bash", _script(root, RESUME), "--workspace", ws, "--agent", "claude",
"--session", name], cwd=str(root), capture_output=True, text=True, timeout=120)
def _assert_root_monitor_started(root, monitor, log, res):
starts = _wait_starts(log, 1)
assert len(starts) == 1, starts
pid, script, cwd, args = starts[0]
assert os.path.realpath(script) == os.path.realpath(monitor)
assert os.path.realpath(cwd) == os.path.realpath(str(root))
assert args == "--subscribe --idle-timeout 0"
assert "watchdog PID: %s" % pid in res.stdout, res.stdout
def test_spawn_path_ensures_the_control_root_monitor(mam_sandbox, mock_herdr, mock_agents, stub, monkeypatch):
root = mam_sandbox
_create(root, str(root), "issue72-spawn", stop=True)
monitor, log = stub(root, "root")
monkeypatch.delenv("MAM_NO_SUBSCRIBER_DAEMON", raising=False)
res = _resume(root, str(root), "issue72-spawn")
assert res.returncode == 0, res.stdout + res.stderr
assert "Successfully resumed" in res.stdout
_assert_root_monitor_started(root, monitor, log, res)
def test_already_running_path_ensures_the_monitor_once(mam_sandbox, mock_herdr, mock_agents, stub, monkeypatch):
root = mam_sandbox
_create(root, str(root), "issue72-alive", stop=False)
monitor, log = stub(root, "root")
monkeypatch.delenv("MAM_NO_SUBSCRIBER_DAEMON", raising=False)
res = _resume(root, str(root), "issue72-alive")
assert res.returncode == 0, res.stdout + res.stderr
assert "already running" in res.stdout
_assert_root_monitor_started(root, monitor, log, res)
# A second resume finds the running daemon and starts no other.
pid = _starts(log)[0][0]
again = _resume(root, str(root), "issue72-alive")
assert again.returncode == 0, again.stdout + again.stderr
assert "watchdog PID: %s" % pid in again.stdout
time.sleep(1)
assert len(_starts(log)) == 1
@pytest.mark.parametrize("stop", [True, False], ids=["spawn", "already-running"])
def test_worktree_session_uses_the_control_root_monitor(mam_sandbox, mock_herdr, mock_agents, stub, monkeypatch, stop):
"""A session whose pane cwd (--workspace) is not the control root still
gets the control root's monitor, never the copy in its own tree."""
root = mam_sandbox
wt = root / "wt-issue72"
wt.mkdir()
name = "issue72-wt-" + ("spawn" if stop else "alive")
_create(root, str(wt), name, stop=stop)
monitor, log = stub(root, "root")
_, wt_log = stub(wt, "worktree")
monkeypatch.delenv("MAM_NO_SUBSCRIBER_DAEMON", raising=False)
res = _resume(root, str(wt), name)
assert res.returncode == 0, res.stdout + res.stderr
_assert_root_monitor_started(root, monitor, log, res)
assert _starts(wt_log) == []
def test_opt_out_starts_nothing_and_reports_a_running_daemon(mam_sandbox, mock_herdr, mock_agents, stub):
"""MAM_NO_SUBSCRIBER_DAEMON=1 (the sandbox default): resume starts no
daemon; one that already runs is still found and reported."""
root = mam_sandbox
assert os.environ.get("MAM_NO_SUBSCRIBER_DAEMON") == "1"
_create(root, str(root), "issue72-optout", stop=True)
monitor, log = stub(root, "root")
res = _resume(root, str(root), "issue72-optout") # spawn path
assert res.returncode == 0, res.stdout + res.stderr
time.sleep(1)
assert _starts(log) == []
pre = subprocess.Popen(["bash", monitor, "--subscribe", "--idle-timeout", "0"], cwd=str(root),
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
start_new_session=True)
try:
assert len(_wait_starts(log, 1)) == 1
res = _resume(root, str(root), "issue72-optout") # already-running path
assert res.returncode == 0, res.stdout + res.stderr
assert "watchdog PID: %d" % pre.pid in res.stdout, res.stdout
time.sleep(1)
assert len(_starts(log)) == 1
finally:
pre.kill()
pre.wait()
def test_a_missing_monitor_warns_and_the_resume_still_succeeds(mam_sandbox, mock_herdr, mock_agents, monkeypatch):
root = mam_sandbox
_create(root, str(root), "issue72-nomon", stop=True)
os.remove(str(root / MONITOR_REL))
monkeypatch.delenv("MAM_NO_SUBSCRIBER_DAEMON", raising=False)
res = _resume(root, str(root), "issue72-nomon")
assert res.returncode == 0, res.stdout + res.stderr
assert "Successfully resumed" in res.stdout
assert "WARNING: could not start the monitor daemon" in res.stderr
+826
View File
@@ -0,0 +1,826 @@
"""Issue #72 - the multi-agent-mux-update skill.
Every test updates a copy of one workspace installed from this checkout
(MAM_REPO_URL, MAM_SKIP_VENV=1). herdr is a fake that answers `session list
--json` and `--session S agent list` from a JSON spec, so the pre-check's view
of the live agents is set per test; the monitor daemon is a stub reconcile.sh
that holds .mam/monitor.lock like the real one. Nothing here touches a real
herdr server or the control root's monitor.
"""
from __future__ import annotations
import fcntl
import hashlib
import json
import os
import shutil
import signal
import sqlite3
import subprocess
import sys
import time
from pathlib import Path
import pytest
import yaml
REPO_ROOT = Path(__file__).resolve().parent.parent
UPDATE_REL = ".agents/skills/multi-agent-mux-update/scripts/update.sh"
MONITOR_REL = ".agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh"
FAKE_HERDR = r'''#!/usr/bin/env python3
import json, os, sys
spec = json.load(open(os.environ["FAKE_HERDR_SPEC"]))
a = sys.argv[1:]
server = "default"
if a[:1] == ["--session"]:
server, a = a[1], a[2:]
with open(os.environ["FAKE_HERDR_SPEC"] + ".calls", "a") as f:
f.write(json.dumps([server] + a) + "\n")
if a[:2] == ["session", "list"]:
if spec.get("fail") == "session-list":
sys.exit(1)
print(json.dumps({"sessions": [{"name": n, "running": r} for n, r in spec.get("sessions", {"default": True}).items()]}))
elif a[:2] == ["agent", "list"]:
if spec.get("fail") == "agent-list:" + server:
sys.exit(1)
print(json.dumps({"result": {"agents": spec.get("agents", {}).get(server, [])}}))
elif a[:2] == ["agent", "get"]:
hit = [x for x in spec.get("agents", {}).get(server, []) if a[2] in (x.get("name"), x.get("pane_id"))]
if not hit:
sys.exit(1)
print(json.dumps({"result": {"agent": hit[0]}}))
elif a[:2] == ["pane", "close"]:
spec.setdefault("agents", {})[server] = [x for x in spec.get("agents", {}).get(server, [])
if x.get("pane_id") != a[2]]
json.dump(spec, open(os.environ["FAKE_HERDR_SPEC"], "w"))
'''
STUB_MONITOR = r'''#!/usr/bin/env bash
# Issue #72 test stub of reconcile.sh: a wrapper whose python child holds monitor.lock.
d="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd -P)"
echo "start $$ $*" >> "$d/.mam/stub-monitor.log"
python3 -c '
import fcntl, os, signal, sys, time
if os.environ.get("STUB_MONITOR_IGNORE_TERM") == "1":
signal.signal(signal.SIGTERM, signal.SIG_IGN)
f = open(sys.argv[1], "w")
fcntl.flock(f, fcntl.LOCK_EX)
time.sleep(600)
' "$d/.mam/monitor.lock" &
child=$!
if [ "${STUB_MONITOR_IGNORE_TERM:-0}" = "1" ]; then
trap '' TERM
else
trap 'kill $child 2>/dev/null; exit 143' TERM
fi
while kill -0 "$child" 2>/dev/null; do wait "$child"; done
'''
def _clean_env():
env = {k: v for k, v in os.environ.items()
if not k.startswith(("HERDR_", "XDG_", "MAM_", "STUB_"))
and k not in ("WORKSPACE_ROOT", "AGENT_SESSIONS_YAML", "DELEGATE_JOB_LOGS_DIR", "PYTHONPATH")}
env.update(MAM_REPO_URL=str(REPO_ROOT), MAM_SKIP_VENV="1",
MAM_INSTALLER_URL="file://" + str(REPO_ROOT / "deploy" / "install.sh"),
MAM_ENV_FILE="/nonexistent/.mam.env", PYTHONDONTWRITEBYTECODE="1")
return env
@pytest.fixture(scope="module")
def template(tmp_path_factory):
ws = tmp_path_factory.mktemp("tpl72") / "ws"
ws.mkdir()
res = subprocess.run(["bash", str(REPO_ROOT / "deploy" / "install.sh"), str(ws)], env=_clean_env(),
capture_output=True, text=True, timeout=600)
assert res.returncode == 0, res.stdout + res.stderr
return ws
class Box:
def __init__(self, root: Path, ws: Path):
self.root, self.ws = root, ws
self.mam = ws / ".mam"
self.bin = root / "bin"
self.spec = root / "herdr.json"
self.env = _clean_env()
self.env["PATH"] = "%s:%s" % (self.bin, os.environ["PATH"])
self.env["FAKE_HERDR_SPEC"] = str(self.spec)
self.helpers = []
self.herdr()
def herdr(self, sessions=None, agents=None, fail=None):
self.spec.write_text(json.dumps({"sessions": sessions or {"default": True},
"agents": agents or {}, "fail": fail}))
def update(self, *args, env=None, timeout=300):
e = dict(self.env, **(env or {}))
return subprocess.run(["bash", str(self.ws / UPDATE_REL), "--yes", *args], cwd=str(self.ws), env=e,
stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=timeout)
def lib(self, script, env=None, timeout=120):
e = dict(self.env, WORKSPACE_ROOT=str(self.ws), **(env or {}))
return subprocess.run(["bash", "-c", 'source "$1/.agents/skills/lib.sh"\n' + script, "_", str(self.ws)],
env=e, capture_output=True, text=True, timeout=timeout)
def installer(self, body):
p = self.root / "installer.sh"
p.write_text("#!/usr/bin/env bash\nws=\"$1\"\n" + body)
self.env["MAM_INSTALLER_URL"] = "file://" + str(p)
def helper(self, *argv, **kw):
p = subprocess.Popen(list(argv), start_new_session=True, **kw)
self.helpers.append(p)
return p
def stub_monitor(self):
p = self.ws / MONITOR_REL
p.write_text(STUB_MONITOR)
def start_monitor(self, env=None):
"""The way create and resume start it: lib.sh start_watchdog (detached, so
not a child of this test that would linger as a zombie)."""
res = self.lib('start_watchdog test "$WORKSPACE_ROOT"', env=dict(env or {}))
assert res.returncode == 0 and res.stdout.strip(), res.stdout + res.stderr
_until(lambda: _lock_held(self.mam / "monitor.lock"))
def monitor_pids(self):
r = subprocess.run(["pgrep", "-f", "bash %s --subscribe" % (self.ws / MONITOR_REL)],
capture_output=True, text=True)
return r.stdout.split()
def monitor_starts(self):
p = self.mam / "stub-monitor.log"
return p.read_text().count("start ") if p.exists() else 0
def _until(cond, timeout=15.0):
end = time.monotonic() + timeout
while time.monotonic() < end:
if cond():
return True
time.sleep(0.1)
raise AssertionError("condition not met within %ss" % timeout)
def _lock_held(path: Path) -> bool:
if not path.exists():
return False
with open(path, "a") as fh:
try:
fcntl.flock(fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
fcntl.flock(fh, fcntl.LOCK_UN)
return False
except BlockingIOError:
return True
@pytest.fixture
def box(template, tmp_path):
root = Path(os.path.realpath(tmp_path))
ws = root / "ws"
shutil.copytree(template, ws, symlinks=True)
(root / "bin").mkdir()
herdr = root / "bin" / "herdr"
herdr.write_text(FAKE_HERDR)
herdr.chmod(0o755)
b = Box(root, ws)
try:
yield b
finally:
for p in b.helpers:
try:
os.killpg(p.pid, signal.SIGKILL)
except OSError:
pass
subprocess.run(["pkill", "-KILL", "-f", str(ws)], capture_output=True)
# --- helpers ---------------------------------------------------------------------
SKIP_SNAPSHOT = ("shim/", "update-admission.lock", "agent-sessions.db.lock", "monitor.lock", "stub-monitor.log")
def snapshot(ws: Path) -> dict:
out = {}
for dp, dns, fns in os.walk(ws):
dns[:] = [d for d in dns if d != "__pycache__"]
for f in fns:
p = Path(dp) / f
rel = str(p.relative_to(ws))
if rel.startswith(".mam/") and any(s in rel for s in SKIP_SNAPSHOT):
continue
out[rel] = "L:" + os.readlink(p) if p.is_symlink() else hashlib.sha256(p.read_bytes()).hexdigest()
return out
def write_rows(ws: Path, rows, **top):
d = dict(top, herdr_sessions=rows)
(ws / ".mam" / "agent-sessions.yaml").write_text(yaml.safe_dump(d, sort_keys=False))
def row(name, status="running", server="default", cwd=None, **extra):
r = {"name": name, "status": status, "role": "creator", "herdr_session": server, "herdr_server": server,
"pane": {"cwd": cwd or "/elsewhere", "cmd": "claude"}}
r.update(extra)
return r
def write_job(d: Path, jid, status="running", session="herdr:x", **extra):
d.mkdir(parents=True, exist_ok=True)
rec = dict({"job_id": jid, "status": status, "agent_session": session}, **extra)
(d / (jid + ".json")).write_text(json.dumps(rec))
def live_marker(path: Path, box: Box):
"""A marker owned by a live process (pid + lstart as loop_lock.sh writes them)."""
p = box.helper("sleep", "120")
ls = subprocess.run(["ps", "-p", str(p.pid), "-o", "lstart="], capture_output=True, text=True).stdout
ls = " ".join(ls.split())
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("pid=%d\nlstart=%s\nstarted=x\n" % (p.pid, ls))
return p
def dead_marker(path: Path):
p = subprocess.Popen(["true"])
p.wait()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("pid=%d\nlstart=Thu Jan 1 00:00:00 1970\nstarted=x\n" % p.pid)
def assert_refused_unchanged(box: Box, before: dict, res, *needles):
assert res.returncode == 1, res.stdout + res.stderr
assert "update refused; nothing was changed" in res.stderr, res.stderr
for n in needles:
assert n in res.stderr, (n, res.stderr)
assert snapshot(box.ws) == before
assert not (box.mam / "update-in-progress").exists(), "the refusing update releases its marker"
assert not (box.mam / "update-backup").exists()
# --- success and the #8 state rules ------------------------------------------------
def test_update_in_place_keeps_mam_and_merges_install_state(box):
mam = box.mam
(mam / "version.txt").write_text("source=old\ncommit=stale0000\nfetched_at=then\nmethod=git\n")
(mam / "herdr_workspace_id").write_text("w9\n")
for d in ("briefs", "diagnostics", "resumelogs", "stoplogs", "jobs", "delegate_job_logs/j1"):
(mam / d).mkdir(parents=True, exist_ok=True)
(mam / d / "keep.txt").write_text(d)
write_job(mam / "jobs", "done1", status="completed")
(mam / "multi-agent-mux-monitor.log").write_text("log\n")
(box.ws / ".mam.env").write_text("MQTT_BROKER=example\n")
with open(mam / "install_state", "a") as fh:
fh.write("custom_marker=kept\n")
write_rows(box.ws, [row("a", status="stopped")], orchestrator_uuids=["u1"])
keep = {k: v for k, v in snapshot(box.ws).items()
if k.startswith((".mam/herdr_workspace_id", ".mam/briefs", ".mam/diagnostics", ".mam/resumelogs",
".mam/stoplogs", ".mam/jobs", ".mam/delegate_job_logs", ".mam/multi-agent-mux-monitor.log"))
or k == ".mam.env"}
res = box.update()
assert res.returncode == 0, res.stdout + res.stderr
after = snapshot(box.ws)
assert {k: after.get(k) for k in keep} == keep, "everything that is not the installer's is kept"
assert "stale0000" not in (mam / "version.txt").read_text()
state = (mam / "install_state").read_text().splitlines()
assert "custom_marker=kept" in state and state.count("gitignore_created=1") == 1, state
assert not (box.ws / ".mam_deploy" / "update.sh").exists()
assert not (mam / "update-backup").exists() and not (mam / "update-in-progress").exists()
assert "Update complete." in res.stdout
def test_old_format_registry_is_converted_by_the_new_code(box):
old = row("old-one", status="stopped", start_command="HERDR_SESSION_NAME=x herdr new-session",
kill_command="HERDR_SESSION_NAME=x herdr kill-session", attach_command="HERDR_SESSION_NAME=x herdr agent attach old-one")
write_rows(box.ws, [old])
res = box.update()
assert res.returncode == 0, res.stdout + res.stderr
assert "registry-format: 1 -> 2, rows changed: 1" in res.stdout
y = yaml.safe_load((box.mam / "agent-sessions.yaml").read_text())
assert y["registry_format"] == 2
conn = sqlite3.connect(str(box.mam / "agent-sessions.db"))
try:
db = {r["name"]: r for r in (json.loads(x[0]) for x in conn.execute("SELECT data FROM sessions"))}
st = json.loads(conn.execute("SELECT data FROM state WHERE id=1").fetchone()[0])
finally:
conn.close()
assert {r["name"]: r for r in y["herdr_sessions"]} == db and st["registry_format"] == 2
assert "kill_command" not in db["old-one"] and "start_command" not in db["old-one"]
def test_no_registry_is_nothing_to_convert(box):
res = box.update()
assert res.returncode == 0, res.stdout + res.stderr
assert "registry: nothing to convert (no registry)" in res.stdout
assert not (box.mam / "agent-sessions.yaml").exists()
def test_a_failed_conversion_keeps_the_install_and_exits_nonzero(box, tmp_path):
src = tmp_path / "src"
shutil.copytree(REPO_ROOT, src, symlinks=True,
ignore=shutil.ignore_patterns(".git", ".venv", ".mam", "__pycache__", "tests", "nats-docker"))
fmt = src / ".agents" / "skills" / "lib_py" / "registry_format.py"
fmt.write_text("import sys\nif __name__ == '__main__':\n sys.exit(3)\n" + fmt.read_text())
box.env["MAM_REPO_URL"] = str(src)
box.stub_monitor()
box.start_monitor()
write_rows(box.ws, [row("a", status="stopped")])
res = box.update()
assert res.returncode == 3, res.stdout + res.stderr
assert "the registry was not converted" in res.stderr
assert (box.ws / ".agents/skills/lib_py/registry_format.py").read_text() == fmt.read_text(), "install kept"
assert box.monitor_pids() and box.monitor_starts() == 2, "the monitor is started again anyway"
assert not (box.mam / "update-backup").exists() and not (box.mam / "update-in-progress").exists()
def test_refuses_outside_an_install_and_without_yes(box):
(box.ws / ".mam_deploy" / "remove.sh").unlink()
res = box.update()
assert res.returncode == 1 and "not an installed MAM workspace" in res.stderr
shutil.copy(REPO_ROOT / "deploy" / "remove.sh", box.ws / ".mam_deploy" / "remove.sh")
res = subprocess.run(["bash", str(box.ws / UPDATE_REL)], env=box.env, stdin=subprocess.DEVNULL,
capture_output=True, text=True, timeout=60)
assert res.returncode == 1 and "-y/--yes" in res.stderr
res = box.update("--force")
assert res.returncode == 2, "--force is not an alias of --yes"
# --- refusals (decision 1) ---------------------------------------------------------
def test_refuses_a_running_row(box):
write_rows(box.ws, [row("busy-creator", cwd="/somewhere/else")])
before = snapshot(box.ws)
res = box.update()
assert_refused_unchanged(box, before, res, "session busy-creator is running", "stop_session.sh --session busy-creator")
def test_refuses_an_unreadable_registry(box):
(box.mam / "agent-sessions.db").write_text("not a database")
before = snapshot(box.ws)
assert_refused_unchanged(box, before, box.update(), "cannot be read")
def test_refuses_a_rowless_agent_on_any_running_server(box):
box.herdr(sessions={"default": True, "picked-by-hand": True, "down": False},
agents={"picked-by-hand": [{"name": "stray", "pane_id": "w3:p1", "cwd": str(box.ws / "sub")}],
"down": [{"name": "ghost", "pane_id": "w4:p1", "cwd": str(box.ws)}]})
before = snapshot(box.ws)
res = box.update()
assert_refused_unchanged(box, before, res, "herdr agent stray (pane w3:p1, server picked-by-hand)")
assert "ghost" not in res.stderr, "a server that is not running is not asked"
calls = (box.root / "herdr.json.calls").read_text()
assert '"down"' not in calls
def test_the_rowless_agent_hint_stops_it_on_the_server_it_was_found_on(box):
"""R3: the printed stop command names the server; without it stop looks on
the workspace's server, where a same-named agent runs outside the workspace."""
write_rows(box.ws, [])
box.herdr(sessions={"default": True, "unrecorded-server": True},
agents={"unrecorded-server": [{"name": "stray", "pane_id": "w3:p1", "cwd": str(box.ws / "sub")}],
"default": [{"name": "stray", "pane_id": "w1:p1", "cwd": str(box.root / "elsewhere")}]})
res = box.update()
assert res.returncode == 1, res.stdout + res.stderr
line = next(ln for ln in res.stderr.splitlines() if "herdr agent stray" in ln)
cmd = line.split("stop it: ", 1)[1]
assert "--herdr-session unrecorded-server" in cmd, cmd
stop = subprocess.run(["bash", "-c", cmd], cwd=str(box.ws), env=box.env, stdin=subprocess.DEVNULL,
capture_output=True, text=True, timeout=120)
assert stop.returncode == 0, stop.stdout + stop.stderr
agents = json.loads(box.spec.read_text())["agents"]
assert agents["unrecorded-server"] == []
assert [x["pane_id"] for x in agents["default"]] == ["w1:p1"], "the same name elsewhere is left alone"
res = box.update()
assert res.returncode == 0, res.stdout + res.stderr
def test_an_agent_outside_the_workspace_does_not_refuse(box):
box.herdr(agents={"default": [{"name": "other", "pane_id": "w1:p2", "cwd": str(box.root / "elsewhere")}]})
res = box.update()
assert res.returncode == 0, res.stdout + res.stderr
@pytest.mark.parametrize("fail", ["session-list", "agent-list:default"])
def test_refuses_when_herdr_cannot_answer(box, fail):
box.herdr(fail=fail)
before = snapshot(box.ws)
assert_refused_unchanged(box, before, box.update(), "cannot list the live agents")
def test_open_jobs_refuse_and_terminal_or_dry_run_do_not(box, tmp_path):
jobs = box.mam / "jobs"
write_job(jobs, "done1", status="completed")
write_job(jobs, "err1", status="error")
write_job(jobs, "can1", status="cancelled")
write_job(jobs, "dry1", status="pending", dry_run=True)
assert box.update().returncode == 0
write_job(jobs, "open1", status="pending", session="remote:x")
before = snapshot(box.ws)
res = box.update()
assert_refused_unchanged(box, before, res, "job open1", "close --job open1 --request cancelled")
(jobs / "open1.json").unlink()
(jobs / "broken.json").write_text("{not json")
before = snapshot(box.ws)
assert_refused_unchanged(box, before, box.update(), "job record %s cannot be read" % (jobs / "broken.json"))
(jobs / "broken.json").unlink()
extra = tmp_path / "mam-jobs-dir"
write_job(extra, "env1", status="running")
box.env["MAM_JOBS_DIR"] = str(extra)
before = snapshot(box.ws)
assert_refused_unchanged(box, before, box.update(), "job env1")
del box.env["MAM_JOBS_DIR"]
@pytest.mark.skipif(os.geteuid() == 0, reason="root reads a mode-000 directory")
def test_a_jobs_directory_that_cannot_be_listed_refuses(box, tmp_path):
"""R2: an unreadable .mam/jobs (or MAM_JOBS_DIR under an unreadable parent)
is not an empty registry."""
jobs = box.mam / "jobs"
write_job(jobs, "hidden1", status="running")
before = snapshot(box.ws)
jobs.chmod(0)
try:
res = box.update()
finally:
jobs.chmod(0o755)
assert_refused_unchanged(box, before, res, "jobs directory %s cannot be listed" % jobs)
(jobs / "hidden1.json").unlink()
parent = tmp_path / "locked"
write_job(parent / "jobs", "env1", status="running")
box.env["MAM_JOBS_DIR"] = str(parent / "jobs")
before = snapshot(box.ws)
parent.chmod(0)
try:
res = box.update()
finally:
parent.chmod(0o755)
assert_refused_unchanged(box, before, res, "jobs directory %s cannot be listed" % (parent / "jobs"))
del box.env["MAM_JOBS_DIR"]
def test_other_control_planes_count_only_jobs_addressed_to_this_registry(box, tmp_path):
other = tmp_path / "other-plane" / "jobs"
write_job(other, "theirs", status="running", session="herdr:not-ours")
write_rows(box.ws, [row("wt-creator", status="stopped", control_plane={"mam_jobs_dir": str(other)})])
assert box.update().returncode == 0, "a job of another registry's session is not ours"
write_job(other, "ours", status="running", session="herdr:wt-creator")
before = snapshot(box.ws)
assert_refused_unchanged(box, before, box.update(), "job ours")
def test_loop_marker_live_refuses_stale_does_not(box):
live_marker(box.mam / "loop-guard-active", box)
before = snapshot(box.ws)
assert_refused_unchanged(box, before, box.update(), "a review loop is running")
dead_marker(box.mam / "loop-guard-active")
res = box.update()
assert res.returncode == 0, res.stdout + res.stderr
assert "stale loop marker" in res.stdout
# --- the calling agent (decision 7, N2) ----------------------------------------------
def _me_env(server="mam-x", pane="w5:p1"):
return {"HERDR_SESSION": server, "HERDR_PANE_ID": pane}
def test_the_calling_agent_its_row_and_its_running_job_are_excluded(box):
me = {"name": "me-creator", "pane_id": "w5:p1", "cwd": str(box.ws)}
box.herdr(sessions={"default": True, "mam-x": True}, agents={"mam-x": [me]})
write_rows(box.ws, [row("me-creator", server="mam-x", cwd=str(box.ws))])
write_job(box.mam / "jobs", "myjob", status="running", session="herdr:me-creator")
res = box.update(env=_me_env())
assert res.returncode == 0, res.stdout + res.stderr
write_job(box.mam / "jobs", "myjob2", status="pending", session="herdr:me-creator")
before = snapshot(box.ws)
assert_refused_unchanged(box, before, box.update(env=_me_env()), "job myjob2")
def test_same_pane_id_on_another_server_is_not_excluded(box):
box.herdr(sessions={"default": True, "mam-x": True, "mam-y": True},
agents={"mam-x": [{"name": "me", "pane_id": "w5:p1", "cwd": str(box.ws)}],
"mam-y": [{"name": "twin", "pane_id": "w5:p1", "cwd": str(box.ws)}]})
before = snapshot(box.ws)
res = box.update(env=_me_env())
assert_refused_unchanged(box, before, res, "herdr agent twin (pane w5:p1, server mam-y)")
assert "agent me " not in res.stderr
def test_a_nameless_caller_pane_is_excluded_by_pane_only(box):
box.herdr(sessions={"default": True, "mam-x": True},
agents={"mam-x": [{"name": None, "pane_id": "w5:p1", "cwd": str(box.ws)}]})
assert box.update(env=_me_env()).returncode == 0
write_job(box.mam / "jobs", "nameless", status="running", session="herdr:")
write_job(box.mam / "jobs", "nonename", status="running", session="herdr:None")
before = snapshot(box.ws)
assert_refused_unchanged(box, before, box.update(env=_me_env()), "job nameless", "job nonename")
@pytest.mark.parametrize("env", [{}, {"HERDR_PANE_ID": "w5:p1"}, {"HERDR_SESSION": "mam-x"}])
def test_nothing_is_excluded_when_the_caller_is_not_identified(box, env):
box.herdr(sessions={"default": True, "mam-x": True},
agents={"mam-x": [{"name": "me", "pane_id": "w5:p1", "cwd": str(box.ws)}]})
before = snapshot(box.ws)
res = box.update(env=env)
assert_refused_unchanged(box, before, res, "herdr agent me", "nothing was excluded as the calling agent")
# --- the monitor (decision 2, U3) ----------------------------------------------------
def test_a_running_monitor_is_stopped_and_started_again(box):
box.stub_monitor()
box.start_monitor()
first = set(box.monitor_pids())
res = box.update()
assert res.returncode == 0, res.stdout + res.stderr
assert "monitor: stopping the daemon" in res.stdout and "monitor: restarted" in res.stdout
now = set(box.monitor_pids())
assert now and not (now & first) and box.monitor_starts() == 2
def test_no_monitor_before_means_none_after(box):
box.stub_monitor()
res = box.update()
assert res.returncode == 0, res.stdout + res.stderr
assert not box.monitor_pids() and box.monitor_starts() == 0
def test_no_subscriber_daemon_flag_wins(box):
box.stub_monitor()
box.start_monitor()
res = box.update(env={"MAM_NO_SUBSCRIBER_DAEMON": "1"})
assert res.returncode == 0, res.stdout + res.stderr
assert "monitor: not restarted (MAM_NO_SUBSCRIBER_DAEMON=1)" in res.stdout
assert not box.monitor_pids()
def test_a_monitor_that_ignores_term_is_killed_restarted_and_nothing_installed(box):
box.stub_monitor()
box.start_monitor(env={"STUB_MONITOR_IGNORE_TERM": "1"})
box.installer("touch \"$ws/INSTALLER-RAN\"\n")
before = snapshot(box.ws)
res = box.update(env={"MAM_UPDATE_MONITOR_STOP_S": "1"})
assert res.returncode == 1, res.stdout + res.stderr
assert "it was killed and is started again" in res.stderr
assert not (box.ws / "INSTALLER-RAN").exists()
assert snapshot(box.ws) == before
assert box.monitor_pids() and box.monitor_starts() == 2
assert not (box.mam / "update-in-progress").exists()
def test_a_monitor_lock_held_by_an_unknown_process_refuses(box):
lock = box.mam / "monitor.lock"
with open(lock, "w") as fh:
fcntl.flock(fh, fcntl.LOCK_EX)
before = snapshot(box.ws)
res = box.update()
assert_refused_unchanged(box, before, res, "monitor.lock is held by a monitor the skills did not start")
# --- rollback (decisions 3, 10; U4) --------------------------------------------------
FAILING_INSTALL = """
echo broken >> "$ws/.agents/skills/lib.sh"
rm -f "$ws/.mam_deploy/remove.sh"
echo new > "$ws/MESSAGING.md"
mkdir -p "$ws/.venv/lib" && echo junk > "$ws/.venv/lib/junk"
echo junk > "$ws/.mam/version.txt"
echo junk > "$ws/.mam/asset_hashes.txt"
mkdir -p "$ws/.agents/skills/half-installed" && echo x > "$ws/.agents/skills/half-installed/f"
python3 -c 'import yaml,sys; p=sys.argv[1]; d=yaml.safe_load(open(p)); d["herdr_sessions"]=[]; yaml.safe_dump(d, open(p, "w"))' "$ws/.mam/agent-sessions.yaml"
exit 7
"""
def test_a_failed_install_rolls_everything_back(box):
write_rows(box.ws, [row("a", status="stopped")])
db = box.mam / "agent-sessions.db"
conn = sqlite3.connect(str(db))
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA wal_autocheckpoint=0")
conn.execute("CREATE TABLE state (id INTEGER PRIMARY KEY, data TEXT)")
conn.execute("CREATE TABLE sessions (name TEXT PRIMARY KEY, status TEXT, pane_cwd TEXT, data JSON)")
conn.execute("INSERT INTO state VALUES (1, ?)", (json.dumps({"orchestrator_uuids": ["in-wal"]}),))
conn.execute("INSERT INTO sessions VALUES ('a', 'stopped', '/x', ?)", (json.dumps(row("a", status="stopped")),))
conn.commit()
assert (box.mam / "agent-sessions.db-wal").stat().st_size > 0, "the commit is still in the WAL"
box.stub_monitor()
box.start_monitor()
box.installer(FAILING_INSTALL)
before = snapshot(box.ws)
try:
res = box.update()
finally:
conn.close()
assert res.returncode == 1, res.stdout + res.stderr
assert "the install failed (rc 7); rolling back" in res.stderr and "rolled back" in res.stdout
after = snapshot(box.ws)
after.pop(".mam/agent-sessions.db", None)
before.pop(".mam/agent-sessions.db", None)
before.pop(".mam/agent-sessions.db-wal", None)
before.pop(".mam/agent-sessions.db-shm", None)
assert after == before, sorted(set(after.items()) ^ set(before.items()))[:10]
assert not (box.ws / "MESSAGING.md").exists() and not (box.ws / ".venv").exists()
check = sqlite3.connect(str(db))
try:
st = json.loads(check.execute("SELECT data FROM state WHERE id=1").fetchone()[0])
finally:
check.close()
assert st == {"orchestrator_uuids": ["in-wal"]}, "the backup carried the WAL's commit"
assert not (box.mam / "update-backup").exists() and not (box.mam / "update-in-progress").exists()
assert box.monitor_pids() and box.monitor_starts() == 2, "the restored tree starts the monitor again"
box.env["MAM_INSTALLER_URL"] = "file://" + str(REPO_ROOT / "deploy" / "install.sh")
res = box.update()
assert res.returncode == 0, "the skill runs again after a rollback: " + res.stdout + res.stderr
# --- leftovers (U4) ------------------------------------------------------------------
def _leftover(box: Box, state):
bk = box.mam / "update-backup"
(bk / "files" / ".agents" / "skills").mkdir(parents=True)
shutil.copy2(box.ws / ".agents/skills/lib.sh", bk / "files/.agents/skills/lib.sh")
(bk / "items").write_text(".agents/skills/lib.sh\nMESSAGING.md\n")
(bk / "absent").write_text("MESSAGING.md\n")
(bk / "registry").mkdir()
(bk / "registry" / "agent-sessions.yaml").write_text("herdr_sessions: []\n")
if state is not None:
(bk / "state").write_text(state + "\n")
return bk
@pytest.mark.parametrize("state", [None, "backing-up", "installed"])
def test_leftover_that_changed_nothing_or_finished_is_removed(box, state):
_leftover(box, state)
(box.ws / "MESSAGING.md").write_text("must survive: nothing is restored\n")
res = box.update()
assert res.returncode == 0, res.stdout + res.stderr
assert "removing the backup a previous update left" in res.stdout
assert (box.ws / "MESSAGING.md").exists()
assert not (box.mam / "update-backup").exists()
def test_leftover_installing_restores_files_but_not_the_registry(box):
_leftover(box, "installing")
pristine = (box.ws / ".agents/skills/lib.sh").read_bytes()
with open(box.ws / ".agents/skills/lib.sh", "a") as fh:
fh.write("# half installed\n")
(box.ws / "MESSAGING.md").write_text("made by the dead install\n")
write_rows(box.ws, [row("written-after-crash", status="stopped")])
box.installer("grep -q 'half installed' \"$ws/.agents/skills/lib.sh\" && exit 9\n"
"test -e \"$ws/MESSAGING.md\" && exit 8\n"
"cp \"$ws/.mam/agent-sessions.yaml\" \"$ws/../seen-registry.yaml\"\nexit 0\n")
res = box.update()
assert res.returncode == 0, res.stdout + res.stderr
assert "the previous update was rolled back" in res.stdout
assert (box.ws / ".agents/skills/lib.sh").read_bytes() == pristine
assert "written-after-crash" in (box.root / "seen-registry.yaml").read_text(), "registry left as it was"
assert not (box.mam / "update-backup").exists()
def test_leftover_whose_restore_fails_is_kept_and_nothing_more_changes(box):
bk = _leftover(box, "installing")
(bk / "items").write_text(".agents/skills/lib.sh\n.agents/skills/missing-from-backup\n")
box.stub_monitor()
box.start_monitor()
box.installer("touch \"$ws/INSTALLER-RAN\"\n")
res = box.update()
assert res.returncode == 1, res.stdout + res.stderr
assert "restoring the previous update's backup failed" in res.stderr
assert bk.exists() and (bk / "state").read_text().strip() == "installing"
assert not (box.ws / "INSTALLER-RAN").exists()
assert not box.monitor_pids(), "the monitor stays stopped"
assert not (box.mam / "update-in-progress").exists()
def test_a_state_write_failure_before_the_install_changes_nothing_and_restarts_the_monitor(box):
real_mv = shutil.which("mv")
fake = box.bin / "mv"
fake.write_text("#!/usr/bin/env bash\n"
"src=\"${@: -2:1}\"\n"
"case \"$src\" in */update-backup/state.tmp) grep -qx installing \"$src\" && exit 1 ;; esac\n"
"exec %s \"$@\"\n" % real_mv)
fake.chmod(0o755)
box.stub_monitor()
box.start_monitor()
box.installer("touch \"$ws/INSTALLER-RAN\"\n")
res = box.update()
assert res.returncode == 1, res.stdout + res.stderr
assert not (box.ws / "INSTALLER-RAN").exists()
assert box.monitor_starts() == 2, "the monitor it stopped is started again"
_until(lambda: bool(box.monitor_pids()))
assert not (box.mam / "update-backup").exists() and not (box.mam / "update-in-progress").exists()
assert "nothing was changed" in res.stderr
def test_the_backup_lives_in_the_workspace_and_is_gone_after_success(box):
box.installer("test -f \"$ws/.mam/update-backup/state\" && grep -qx installing \"$ws/.mam/update-backup/state\" "
"&& test -f \"$ws/.mam/update-backup/files/.agents/skills/lib.sh\" && touch \"$ws/../saw-backup\"\n")
res = box.update()
assert res.returncode == 0, res.stdout + res.stderr
assert (box.root / "saw-backup").exists()
assert not (box.mam / "update-backup").exists()
# --- the marker and the admission lock (decision 8) -----------------------------------
def test_a_live_update_marker_refuses_a_second_update_and_is_left_alone(box):
live_marker(box.mam / "update-in-progress", box)
text = (box.mam / "update-in-progress").read_text()
before = snapshot(box.ws)
res = box.update()
assert res.returncode == 1 and "another update is in progress" in res.stderr, res.stderr
assert (box.mam / "update-in-progress").read_text() == text
assert snapshot(box.ws) == before
def test_a_stale_update_marker_is_taken_over(box):
dead_marker(box.mam / "update-in-progress")
res = box.update()
assert res.returncode == 0, res.stdout + res.stderr
assert "stale update marker" in res.stdout
assert not (box.mam / "update-in-progress").exists()
def test_two_updates_at_once_one_proceeds(box):
box.installer("touch \"$ws/../first-installing\"; sleep 4; exit 0\n")
first = box.helper("bash", str(box.ws / UPDATE_REL), "--yes", cwd=str(box.ws), env=box.env,
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
_until(lambda: (box.root / "first-installing").exists())
marker = (box.mam / "update-in-progress").read_text()
second = box.update()
assert second.returncode == 1 and "another update is in progress" in second.stderr, second.stderr
assert (box.mam / "update-in-progress").read_text() == marker, "the running update's marker is untouched"
out, err = first.communicate(timeout=120)
assert first.returncode == 0, out + err
def test_entries_are_refused_while_the_copy_installs(box):
box.installer('if bash -c \'source "$1/.agents/skills/lib.sh" && mam_update_admit\' _ "$ws" 2> "$ws/../admit.err"; '
'then echo admitted > "$ws/../admit"; else echo refused > "$ws/../admit"; fi\n')
res = box.update()
assert res.returncode == 0, res.stdout + res.stderr
assert (box.root / "admit").read_text().strip() == "refused"
assert "an update of %s is in progress" % box.ws in (box.root / "admit.err").read_text()
@pytest.mark.parametrize("wait,needle", [("30", "session late-row is running"), ("1", "admission lock past")])
def test_an_entry_holding_the_admission_lock_is_waited_for_or_refuses(box, wait, needle):
script = ('mam_update_admit || exit 1\necho admitted\nsleep 3\n'
'printf "herdr_sessions:\\n- name: late-row\\n status: running\\n" > "$WORKSPACE_ROOT/.mam/agent-sessions.yaml"\n'
'mam_update_admit_release\nsleep 30\n')
entry = box.helper("bash", "-c", 'source "$1/.agents/skills/lib.sh"\n' + script, "_", str(box.ws),
env=dict(box.env, WORKSPACE_ROOT=str(box.ws)), stdout=subprocess.PIPE, text=True)
assert entry.stdout.readline().strip() == "admitted"
box.installer("touch \"$ws/INSTALLER-RAN\"\n")
res = box.update(env={"MAM_UPDATE_ADMISSION_WAIT_S": wait})
assert res.returncode == 1, res.stdout + res.stderr
assert needle in res.stderr, res.stderr
assert not (box.ws / "INSTALLER-RAN").exists()
assert not (box.mam / "update-in-progress").exists()
# --- the first transition (decision 4, criterion 5) ------------------------------------
def test_the_old_updater_installs_the_skill_which_then_converts_the_registry(tmp_path):
"""A workspace installed by v4.11.0 still has .mam_deploy/update.sh. Run once,
it installs the new version - the skill, and no updater left behind - and
copies the registry unconverted; the skill run right after converts it."""
old_src = tmp_path / "v4.11.0"
old_src.mkdir()
arch = subprocess.run(["git", "-C", str(REPO_ROOT), "archive", "0e1e2e8"], capture_output=True)
if arch.returncode != 0:
pytest.skip("no git history to rebuild v4.11.0 from")
subprocess.run(["tar", "-x", "-C", str(old_src)], input=arch.stdout, check=True)
root = Path(os.path.realpath(tmp_path))
ws = root / "ws"
ws.mkdir()
(root / "bin").mkdir()
(root / "bin" / "herdr").write_text(FAKE_HERDR)
(root / "bin" / "herdr").chmod(0o755)
b = Box(root, ws)
res = subprocess.run(["bash", str(old_src / "deploy" / "install.sh"), str(ws)],
env=dict(b.env, MAM_REPO_URL=str(old_src)), capture_output=True, text=True, timeout=600)
assert res.returncode == 0, res.stdout + res.stderr
assert (ws / ".mam_deploy" / "update.sh").exists()
write_rows(ws, [row("old-one", status="stopped", kill_command="x", start_command="y")])
res = subprocess.run(["bash", str(ws / ".mam_deploy" / "update.sh"), "--force"], cwd=str(ws), env=b.env,
capture_output=True, text=True, timeout=600, stdin=subprocess.DEVNULL)
assert res.returncode == 0, res.stdout + res.stderr
assert (ws / UPDATE_REL).exists() and not (ws / ".mam_deploy" / "update.sh").exists()
assert "registry_format" not in yaml.safe_load((ws / ".mam" / "agent-sessions.yaml").read_text())
res = b.update()
assert res.returncode == 0, res.stdout + res.stderr
assert "registry-format: 1 -> 2, rows changed: 1" in res.stdout
assert yaml.safe_load((ws / ".mam" / "agent-sessions.yaml").read_text())["registry_format"] == 2
+18 -4
View File
@@ -1,6 +1,8 @@
#!/usr/bin/env python3
"""Issue #8 — update.sh must keep the installer's fresh version.txt and
asset_hashes.txt instead of restoring the pre-update copies.
"""Issue #8 — an update must keep the installer's fresh version.txt and
asset_hashes.txt instead of restoring the pre-update copies. Since Issue #72
the update is the multi-agent-mux-update skill (in place, no remove.sh) and
no .mam_deploy/update.sh is installed.
Restoring them left version.txt naming the previous commit and made remove.sh
compare against stale hashes, so every updated-but-pristine file was backed up
@@ -51,8 +53,19 @@ def test_update_keeps_fresh_version_and_hashes_and_merges_install_state(installe
with open(mam / "install_state", "a") as fh:
fh.write("custom_marker=kept\n")
res = subprocess.run(["bash", str(ws / ".mam_deploy" / "update.sh"), "--force"],
cwd=str(ws), env=env, capture_output=True, text=True, timeout=600)
assert not (ws / ".mam_deploy" / "update.sh").exists()
upd_env = {k: v for k, v in env.items() if not k.startswith("HERDR_")
and k not in ("WORKSPACE_ROOT", "AGENT_SESSIONS_YAML", "MAM_JOBS_DIR")}
upd_env["MAM_NO_SUBSCRIBER_DAEMON"] = "1"
# The update lists the live herdr agents; never ask a real server here.
fake = ws.parent / "fake-herdr-bin"
fake.mkdir()
(fake / "herdr").write_text('#!/bin/sh\necho \'{"sessions": []}\'\n')
(fake / "herdr").chmod(0o755)
upd_env["PATH"] = "%s:%s" % (fake, upd_env["PATH"])
res = subprocess.run(["bash", str(ws / ".agents/skills/multi-agent-mux-update/scripts/update.sh"), "--yes"],
cwd=str(ws), env=upd_env, capture_output=True, text=True, timeout=600,
stdin=subprocess.DEVNULL)
assert res.returncode == 0, res.stderr + res.stdout
version = (mam / "version.txt").read_text()
@@ -64,3 +77,4 @@ def test_update_keeps_fresh_version_and_hashes_and_merges_install_state(installe
state = (mam / "install_state").read_text()
assert "custom_marker=kept" in state, state
assert state.count("gitignore_created=1") == 1, "merged keys must not be duplicated:\n" + state
assert not (ws / ".mam_deploy" / "update.sh").exists()
+2 -1
View File
@@ -239,7 +239,8 @@ def test_lib_sh_no_local_in_shim_heredoc():
with open(lib_path, "r", encoding="utf-8") as f:
content = f.read()
start_idx = content.find("cat <<'EOF' > \"$tmp_file\"")
# Issue #71: the shim is written as { printf ...; declare -f ...; cat <<'EOF' ... } > "$tmp_file"
start_idx = content.find("cat <<'EOF'\n")
end_idx = content.find("\nEOF\n", start_idx)
assert start_idx != -1 and end_idx != -1
+5 -1
View File
@@ -217,7 +217,11 @@ def test_stop_is_already_stopped_not_found(mam_sandbox):
assert res.returncode == 1
def test_stop_session_invalid_agent_suffix(mam_sandbox):
"""Test stop_session.sh fails when agent cannot be inferred from session name."""
"""Test stop_session.sh fails when agent cannot be inferred from session name.
Issue #71: a session with no row needs no agent kind (stop closes it herdr
only), so the registered row here carries no agent, suffix or pane.cmd."""
(mam_sandbox / ".mam" / "agent-sessions.yaml").write_text(
"herdr_sessions:\n- name: bad-session-name\n status: running\n pane: {}\n")
script_path = mam_sandbox / "skills" / "multi-agent-mux-stop" / "scripts" / "stop_session.sh"
res = subprocess.run(["bash", str(script_path), "--session", "bad-session-name"], capture_output=True, text=True)
assert res.returncode == 2
+10 -11
View File
@@ -214,13 +214,13 @@ def test_comp_create_herdr_session_default_preserved(mam_sandbox, mock_herdr, mo
assert s["name"] == "custom-proj-default-claude"
assert s["herdr_session"] == "default"
assert s["herdr_server"] == "default"
assert "HERDR_SESSION_NAME=default" in s["start_command"]
assert "HERDR_SESSION_NAME=default" in s["attach_command"]
assert "HERDR_SESSION_NAME=default" in s["kill_command"]
# Issue #71: the native form, `default` named too; no start/kill_command
assert s["attach_command"] == "herdr --session default agent attach custom-proj-default-claude"
assert "start_command" not in s and "kill_command" not in s
def test_comp_create_herdr_session_yaml_propagation(mam_sandbox, mock_herdr, mock_agents):
"""Verify HERDR_SESSION_NAME propagation into start_command / herdr_session YAML field."""
"""Verify HERDR_SESSION_NAME propagation into attach_command / herdr_session YAML field."""
script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh"
res = subprocess.run([
"bash", str(script),
@@ -242,9 +242,8 @@ def test_comp_create_herdr_session_yaml_propagation(mam_sandbox, mock_herdr, moc
assert s["name"] == "custom-proj-creator-claude"
assert s["herdr_session"] == "isolated-suite-01"
assert s["herdr_server"] == "isolated-suite-01"
assert "HERDR_SESSION_NAME=isolated-suite-01" in s["start_command"]
assert "HERDR_SESSION_NAME=isolated-suite-01" in s["attach_command"]
assert "HERDR_SESSION_NAME=isolated-suite-01" in s["kill_command"]
assert s["attach_command"] == "herdr --session isolated-suite-01 agent attach custom-proj-creator-claude"
assert "start_command" not in s and "kill_command" not in s
def test_comp_create_herdr_workspace_parsing_and_env_fallback(mam_sandbox, mock_herdr, mock_agents):
@@ -332,9 +331,9 @@ def test_comp_create_herdr_workspace_yaml_propagation(mam_sandbox, mock_herdr, m
assert s["herdr_session"] == "isolated-sock-01"
assert s["herdr_server"] == "isolated-sock-01"
assert s["herdr_workspace"] == "distinct-ws-label"
assert "distinct-ws-label" not in s["start_command"]
assert "distinct-ws-label" not in s["attach_command"]
assert "distinct-ws-label" not in s["kill_command"]
assert s["attach_command"] == "herdr --session isolated-sock-01 agent attach custom-ws-creator-claude"
assert "start_command" not in s and "kill_command" not in s
def test_create_does_not_inherit_a_stale_workspace_label(mam_sandbox, mock_herdr, mock_agents):
@@ -573,8 +572,8 @@ d['herdr_sessions'] = [{{
assert s["status"] == "running"
assert s["herdr_session"] == "NEW-HERDR-SESSION"
assert s["herdr_server"] == "NEW-HERDR-SESSION"
assert "HERDR_SESSION_NAME=NEW-HERDR-SESSION" in s["attach_command"]
assert "HERDR_SESSION_NAME=NEW-HERDR-SESSION" in s["kill_command"]
assert s["attach_command"] == "herdr --session NEW-HERDR-SESSION agent attach test-proj-creator-claude"
assert "start_command" not in s and "kill_command" not in s
def test_comp_resume_herdr_workspace_propagation(mam_sandbox, mock_herdr, mock_agents):
+4
View File
@@ -393,6 +393,10 @@ def test_integration_invalid_arguments_exit_statuses(mam_sandbox, mock_herdr, mo
assert "ERROR: --session required" in res.stderr
# 3. stop_session.sh cannot infer agent from weird session name
# (Issue #71: a session with no row needs no agent kind, so the row is
# registered here, with no agent, suffix or pane.cmd)
(tmp_path / ".mam" / "agent-sessions.yaml").write_text(
"herdr_sessions:\n- name: bad-name\n status: running\n pane: {}\n")
res = subprocess.run(["bash", str(stop_script), "--session", "bad-name"], capture_output=True, text=True, cwd=str(tmp_path))
assert res.returncode == 2
assert "ERROR: cannot infer agent" in res.stderr