# πŸ”¬ Technical Analysis Report: Issue #3 (Rev. 3) ## Herdr Daemon Process Separation (`setsid`) & 0-Turn Stopped Session Resume Recovery Gap - **Author**: `creator-agy-01` (Worker / Lead Technical Analyst) - **Job ID**: `6b391a80` (Follow-up to `842b96bf`, incorporating peer review feedback from `planner-reviewer-claude-01` [job `0338e7de`] and `reviewer-opencode-01` [job `7b6c16df`]) - **Target Issue**: Issue #3 (`[Bug] herdr 데λͺ¬ ν”„λ‘œμ„ΈμŠ€ 뢄리(setsid) λˆ„λ½μœΌλ‘œ μΈν•œ μ„Έμ…˜ 리셋 및 UUID λ―Έν• λ‹Ή μ„Έμ…˜ resume λ°λ“œλ½ 문제`) - **Analyzed Components**: 1. `.agents/skills/lib.sh` (lines 198–220, `.mam/shim/herdr` daemon bootstrap) 2. `.agents/skills/lib_py/verify_session.py` (lines 83–109, `verify_session_uuid` escape hatch at lines 99–101) 3. `.agents/skills/lib_py/workspace_uuid.py` (lines 55–80, `find_workspace_uuid`) 4. `.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh` (lines 50–57, 98–109) 5. `.agents/skills/multi-agent-mux-create/scripts/create_session.sh` (lines 177–195, 370–440) 6. `.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh` (lines 183–191, 280–293) 7. `tests/test_uuid_target.py` (`test_t8_resume_unmaterialized_assigned_id`) - **Scope Restriction**: Analysis and defect determination only β€” no implementation or code changes performed in this job. --- ## 1. Executive Summary & Defect Verdict Matrix | Item # | Reported Issue | Target Location | Live Mechanism & Root Cause | Structural Defect Status | Severity | |:---:|---|---|---|:---:|:---:| | **1** | Missing `setsid` on headless Herdr daemon bootstrap causes daemon termination and session resets during process-group teardown. | `lib.sh:211` (and `.mam/shim/herdr`) | Uses `nohup "$REAL_HERDR" ... server >/dev/null 2>&1 & disown`. While `SIGHUP` is ignored on normal subshell exit, the daemon remains in the calling subshell's Process Group (PGID) because `set -m` is disabled in non-interactive scripts. Process group signal broadcasts (`SIGTERM`/`SIGINT` from test runners, CI fixtures, timeout wrappers, or supervisor group kills) kill the Herdr daemon, terminating all child panes and resetting session state. | **CONFIRMED REAL DEFECT** (Process Group Signal Vulnerability) | **HIGH** (Causes total session/pane loss during process group teardown) | | **2 (Class A)** | Resuming a Discovery-bucket session (`agy`/`hermes`/`opencode`) stopped at 0-turns (before first message) fails with an unrecoverable error. | `resume_session.sh:54-57` | Created with `session_id_source: pending-discovery` and own-key `null`. When stopped at 0-turns, no disk artifact exists, `resolve_session_id.sh` outputs empty string `""`, and `resume_session.sh` executes a hard exit (`[ -z "$UUID" ] -> exit 1`), blocking automated recovery. | **CONFIRMED REAL DEFECT** (Automation / Reconciler Recovery Gap) | **MEDIUM** (Affects 0-turn recovery for `agy`, `hermes`, `opencode`) | | **2 (Class B)** | Resuming an Assigned-bucket session (`claude`/`grok`) stopped at 0-turns. | `verify_session.py:99-101`, `resume_session.sh` | Created with `session_id_source: assigned` and `session_id_verified: false`. When stopped at 0-turns, own-key is preserved. `verify_session_uuid` in `revalidate` mode hits the early-return escape hatch at `verify_session.py:99-101` and returns `True` without checking disk artifacts. `resume_session.sh` succeeds with RC=0 using `--session-id `. | **NOT A DEFECT** (Working as designed; verified by passing `test_t8`) | **N/A** (0-turn resume already functions correctly) | --- ## 2. Deep-Dive Analysis: Item 1 (Herdr Daemon `setsid` Omission) ### 2.1. Codebase Verification In `.agents/skills/lib.sh` (lines 198–220), inside the heredoc template that generates `.mam/shim/herdr`: ```bash # lib.sh:207-219 if [ "$_mam_session_running" != "1" ]; then # Headless bootstrap avoids herdr's "nested herdr is disabled" guard... nohup "$REAL_HERDR" --session "$_MAM_SESSION" server >/dev/null 2>&1 & _mam_server_pid=$! disown 2>/dev/null || true for _mam_wait_i in $(seq 1 40); do [ -S "${HOME:-$HOME_DIR}/.config/herdr/sessions/$_MAM_SESSION/herdr.sock" ] && break kill -0 "$_mam_server_pid" 2>/dev/null || break sleep 0.25 done fi ``` ### 2.2. OS Process Group & Signal Dynamics 1. **The Role of `nohup` and `disown`**: - `nohup` ensures `SIGHUP` is ignored (`SIG_IGN`). - `disown` unlinks the job from the current bash shell's active job table so the shell does not emit `SIGHUP` upon exit. - Upon clean subshell termination, the child is reparented to `init`/`launchd` (PID 1) and survives. 2. **The Vulnerability β€” Process Group (PGID) Retention**: - In non-interactive bash scripts (where job monitor `set -m` is disabled by default), background processes (`&`) are placed in the **same Process Group (PGID)** as the caller. - `nohup` and `disown` **do not create a new process group or session ID**. - Inspection of live production processes confirmed this vulnerability: ``` PID 7623 PGID 7526 PPID 1 TTY ttys001 /opt/homebrew/bin/herdr --session multi-agent-mux server ``` The daemon is reparented to PPID 1 but continues to run in its dead spawner's PGID (7526) and remains attached to controlling TTY `ttys001`. - When test runners (e.g. pytest subshell teardowns), timeout wrappers (`timeout --kill-after ...`), CI runners, or parent supervisor scripts terminate a workflow via process group kill (`kill -- -$PGID` or `kill -TERM -$PGID`), the signal (`SIGTERM` or `SIGINT`) is broadcast to **every process in that PGID**. - Because `nohup` only shields against `SIGHUP`, the Herdr daemon receives `SIGTERM`/`SIGINT` and terminates immediately. 3. **Consequences**: - The Herdr daemon socket (`herdr.sock`) is deleted. - All active workspaces, tabs, and agent panes managed under that Herdr session instance are destroyed, resulting in an unrecoverable runtime session reset. ### 2.3. Platform Portability Consideration (macOS vs. Linux) - On standard Linux systems, `setsid` is commonly installed via `util-linux`. - On **macOS (Darwin)**, the `setsid` command-line utility **is not installed by default** (`command -v setsid` exits with 1). Calling `setsid "$REAL_HERDR" ...` in bash causes `command not found: setsid`. - **Portable Solution**: Python is a mandatory runtime dependency across MAM (`lib_py`). In Python, `subprocess.Popen(..., start_new_session=True)` natively calls `os.setsid()` in the child process before `exec` on all POSIX platforms (macOS, Linux, BSD), providing true session and process group detachment. --- ## 3. Deep-Dive Analysis: Item 2 (0-Turn Stopped Session Resume Recovery Gap) ### 3.1. Breakdown by Spawn Class #### Class A: Discovery-Bucket Agents (`agy`, `hermes`, `opencode`) β€” [CONFIRMED REAL DEFECT] 1. **Creation**: - `create_session.sh` (lines 189–193) spawns these agents without a UUID (`agy --dangerously-skip-permissions`, `hermes --yolo --accept-hooks`, `opencode --auto --agent build`). - In `.mam/agent-sessions.yaml`, the row is stamped with `session_id_source: pending-discovery` and own-keys as `null`/empty. 2. **0-Turn Stop**: - When stopped before sending any message, `stop_session.sh:185` calls `capture_conversation_id`, which finds no on-disk conversation files and returns `""`. - `stop_session.sh:281` (`if captured and not purge:`) is skipped; `resumable: true` is not set and own-keys remain `null`. The row is marked `status: stopped`. 3. **Resume Attempt**: - `resume_session.sh:51` calls `resolve_session_id.sh`, which searches the YAML row and scans disk via `find_workspace_uuid`. Both are empty, returning `""`. - `resume_session.sh:54-57` executes: ```bash if [ -z "$UUID" ]; then echo "ERROR: No saved session for $WORKSPACE ($AGENT). Use multi-agent-mux-create first." >&2 exit 1 fi ``` - Hard exit with code 1. 4. **Impact**: - Reconcilers and automated workflows that hold only `--session`, `--workspace`, and `--agent` cannot recover a stopped 0-turn Class A session via `resume_session.sh`. - Handoff to `create_session.sh` fails because `create_session.sh` strictly requires `--role` (`[ -n "$ROLE" ] || exit 2`). --- #### Class B: Assigned-Bucket Agents (`claude`, `grok`) β€” [NOT A DEFECT, WORKING AS DESIGNED] 1. **Creation**: - `create_session.sh:178-180` pre-allocates a UUID via `mam_gen_uuid` and records it into `claude_session_id_own` / `grok_session_id_own` with `session_id_source: assigned` and `session_id_verified: false` (`create_session.sh:412-413`/`435-436`). 2. **0-Turn Stop**: - When stopped before sending any message, `stop_session.sh` does **not** null or erase the create-time assigned own-key (`stop_session.sh:281` only updates if `captured` is non-empty, leaving existing own-keys intact). 3. **Resume Attempt & The `verify_session.py` Escape Hatch**: - When `resolve_session_id.sh` calls `find_workspace_uuid` (`lib_py/workspace_uuid.py:65, 72`), it evaluates candidate own-keys using `verify_session_uuid(ws, agent, cand, s, mode="revalidate")`. - In `lib_py/verify_session.py` (lines 99–101): ```python if (mode == "revalidate" and row.get("session_id_source") == "assigned" and not row.get("session_id_verified")): return True ``` - Because the session is stopped at 0 turns, `session_id_source` is `"assigned"` and `session_id_verified` remains `False`. - **`verify_session_uuid` early-returns `True` immediately**, bypassing the on-disk transcript check (`adapter.verify_artifact()`). - `find_workspace_uuid` returns the candidate UUID. - `resume_session.sh` calls `resume-spec` (`claude.py:97`), which detects `materialized: False` and generates: ```bash CMD_FULL="${RESOLVED_BIN} --dangerously-skip-permissions --session-id ${SESSION_UUID}" ``` - `resume_session.sh` executes with **RC=0**, successfully spawning the agent pane with its pre-assigned UUID! 4. **Empirical Evidence**: - Covered and protected by existing regression test: ``` tests/test_uuid_target.py::test_t8_resume_unmaterialized_assigned_id PASSED [100%] ``` - All 12/12 tests in `tests/test_uuid_target.py` pass. --- ## 4. Architectural Recommendations for Future Planning 1. **For Item 1 (Headless Herdr Daemon Detachment)**: - Replace `nohup "$REAL_HERDR" ... server & disown` in `.mam/shim/herdr` / `lib.sh` with a portable Python daemon spawner: ```python python3 -c "import subprocess, sys; subprocess.Popen(sys.argv[1:], start_new_session=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL)" "$REAL_HERDR" --session "$_MAM_SESSION" server ``` - This isolates the daemon in its own Session ID and Process Group across macOS and Linux, protecting it from parent process-group signals (`SIGTERM`/`SIGINT`). 2. **For Item 2 (Class A 0-Turn Stopped Session Recovery)**: - Target **Class A agents (`agy`, `hermes`, `opencode`) only**: - When `UUID` is empty for a `status: stopped` session in `resume_session.sh`, instead of hard-failing at line 54, allow `resume_session.sh` to fall back to launching the initial clean spawn command (`CMD_FULL`) into the pane (or allow `--role`-less creation handoff). - **Do NOT modify Class B**: Preserve the existing `verify_session.py:99-101` escape hatch and `test_t8` contract unchanged. ---