# ๐Ÿ“‹ Implementation Plan โ€” Issue #3 Fix (Job `a9c8d6d3`) - **Planner**: `planner-reviewer-claude-01` - **Based on**: `.agents/reports/creator-agy-01/issue-3-analysis.md` (Rev. 3, job `6b391a80`), independently validated PASS by both `planner-reviewer-claude-01` (job `9406c304`) and `reviewer-opencode-01` across the peer-review cycle. - **Scope**: implement both confirmed defects from the Rev. 3 analysis, add regression tests, verify the full suite. --- ## 1. Item 1 โ€” Headless herdr daemon process-group detachment **Defect** (Rev. 3, CONFIRMED, HIGH): `.agents/skills/lib.sh:207-219` (inside the heredoc that generates `.mam/shim/herdr`) launched the headless daemon via `nohup "$REAL_HERDR" ... server >/dev/null 2>&1 & disown`. `nohup` only ignores `SIGHUP`; `disown` only drops bash's own job-table tracking. Neither creates a new process group or session, so the daemon stays in the caller's PGID and dies when a process-group signal (`SIGTERM`/`SIGINT` from a test runner, timeout wrapper, or supervisor teardown) is broadcast โ€” matching the live evidence from three independent reviewers' process-table inspections (PID 7623/PGID 7526/PPID 1, still TTY-attached, never `setsid()`'d). **Fix**: replaced the `nohup ... & disown` line with a Python `subprocess.Popen(..., start_new_session=True, ...)` spawner (invoked via `python3 -c '...'`), which calls `os.setsid()` in the child before `exec` โ€” portable across macOS (no `setsid(1)` binary by default) and Linux. `disown` is no longer needed since the process is no longer a bash job at all (spawned by the python3 child, not via `&`). The `kill -0 "$_mam_server_pid"` liveness-wait loop is unchanged; only how the PID is obtained changed (captured from the Python spawner's stdout instead of `$!`). **File**: `.agents/skills/lib.sh:207-224` (the generated `.mam/shim/herdr` is a runtime artifact regenerated by `_init_herdr_isolation`, not git-tracked โ€” no separate edit needed there). **Test**: `tests/test_herdr_shim_contract.py::test_h26_daemon_spawn_uses_process_group_detachment` โ€” (a) static check that the generated shim no longer contains the old `nohup ...server` pattern and does contain `start_new_session=True`; (b) behavioral check that extracts the *actual shipped* spawner statement from the generated shim and runs it standalone against a fake `REAL_HERDR` (a plain `sleep 30`), asserting the spawned process's PGID differs from the calling shell's PGID โ€” i.e. genuine detachment, not just a textual change. Verified fail-old/pass-new: reverting `lib.sh` to the pre-fix version makes this test fail (old `nohup` pattern still present); restoring the fix makes it pass. ## 2. Item 2 โ€” Class A (`agy`/`hermes`/`opencode`) 0-turn resume fallback **Defect** (Rev. 3, CONFIRMED, MEDIUM โ€” Class A only): a session for `agy`/`hermes`/`opencode` stopped before its first message has `session_id_source: pending-discovery` and a null own-key. `resolve_session_id.sh` correctly returns `""`, and `resume_session.sh:54-57` hard-exits (RC=1) telling the caller to use `multi-agent-mux-create`, which in turn requires `--role` (`create_session.sh:96`) โ€” an argument resume callers don't hold. **Explicitly not a defect for Class B** (`claude`/`grok`): their `verify_session.py:99-101` `assigned`+`unverified` escape hatch already resolves the pre-assigned UUID without requiring an on-disk transcript, proven by the pre-existing `test_t8_resume_unmaterialized_assigned_id` (still green, untouched). **Fix** (`.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh`): - When `resolve_session_id.sh` returns an empty UUID, branch on agent: `agy`/`hermes`/`opencode` set `FRESH_SPAWN=1` and continue instead of exiting; `claude`/`grok`/any other agent hit the exact original hard-exit (byte-identical error message and RC=1) โ€” the Class B path is completely unmodified. - When `FRESH_SPAWN=1`, `CMD_FULL` is computed via `lib_py.agents spawn-spec` (the same call `create_session.sh` uses for a first-time launch) instead of `resume-spec`, with a matching inline fallback case statement for `agy`/`hermes`/`opencode` only. - This bypasses the broken `resume_session.sh โ†’ create_session.sh --role` handoff entirely rather than trying to make `create_session.sh` callable without a role โ€” the recovery happens inside `resume_session.sh` itself, reusing the same command the agent was originally spawned with. **Fix** (`.agents/skills/multi-agent-mux-resume/scripts/update_yaml_resumed.sh`): the final YAML update needs `--uuid`, which is empty for a fresh-spawn resume. The hard `[ -n "$UUID" ] || exit 2` guard was relaxed to allow empty (documented in `usage()`), and a new optional `--cmd-full` flag lets `resume_session.sh` pass through the already-computed spawn command for display. The Python payload: - `last_visible_status` branches: `"resumed conversation {uuid} ..."` when non-empty, `"resumed (fresh spawn, no prior turn) ..."` when empty. - **Only the `agy`/`hermes`/`opencode` blocks** got an `if uuid: ... else: ...` guard (own-key set + uuid-interpolated `cmd_full` when present; `--cmd-full` passthrough with a static fallback when absent). **The `claude` and `grok` blocks are byte-identical to before** โ€” per the brief's explicit instruction, Class B is never touched. **Files**: `.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh`, `.agents/skills/multi-agent-mux-resume/scripts/update_yaml_resumed.sh`. **Tests**: - `tests/test_uuid_target.py::test_t14_resume_class_a_fresh_spawn_fallback` โ€” creates an `agy` session, stops it at 0 turns, confirms the YAML row has `status: stopped` and no own-key, then runs `resume_session.sh --dry-run` and asserts RC=0 with a plain spawn command (`--dangerously-skip-permissions`, no `--conversation`). Verified fail-old/pass-new: reverting `resume_session.sh` alone reproduces the old hard-fail (`ERROR: No saved session for ...`) and the test fails; restoring the fix makes it pass. - `tests/test_uuid_target.py::test_t15_resume_class_b_still_hard_fails_when_truly_unresolvable` โ€” a genuinely never-created `claude` session must still hard-fail with the original error and RC=1, guarding against the fallback ever leaking into Class B. ## 3. Verification - Targeted suite (`test_tier1-4`, `test_uuid_target`, `test_c1_tui_readiness`, `test_herdr_shim_contract`): 164 passed pre-existing + 3 new = 167, all green, before adding the final full-suite run. - Full suite (`tests/`): run in background; result to be confirmed and cited in the final job report. - No changes to `verify_session.py`, `workspace_uuid.py`, or the `claude`/`grok` branches of `update_yaml_resumed.sh` โ€” the Class B escape hatch and `test_t8` contract are untouched, per the brief's explicit requirement. ## 4. SemVer Both changes are backward-compatible bug fixes (no public interface/behavior removed; `resume_session.sh`'s new fallback only activates on a previously-hard-failing input; `update_yaml_resumed.sh`'s relaxed `--uuid` requirement is additive). This qualifies as a PATCH under SemVer 2.0.0 ยง6. Version bump and `VERSIONS.md`/`SKILL.md` changelog entries are left for a subsequent release-packaging job (out of this job's stated scope, which is analysis-driven code improvement, not release cutting). --- ## Rev. 2 โ€” Refinement in response to `creator-agy-01`'s architectural challenge (job `c4b0a075`) **Independent verification of the challenge** (before accepting it): re-read the live, already-implemented `update_yaml_resumed.sh` and confirmed the challenge's citation is exact โ€” the existing-row branch (`else:` at line 150, running through line 163, immediately before the shared `target['status'] = 'running'` at line 165) genuinely never touches `herdr_session_epoch` or `herdr_session_created_at`. Cross-checked the two supporting mechanisms it depends on: `lib_py/verify_session.py:89` (`epoch = row.get("herdr_session_epoch", 0) if mode == "discover" else 0`) confirms the epoch watermark only matters in `discover` mode, and `lib_py/agents/adapters/agy.py:49` (`if ctx.epoch and os.path.getmtime(path) < ctx.epoch: return False`) confirms the exact `mtime >= epoch` acceptance rule the challenge describes. `reconcile.sh` does call `verify_session_uuid(..., mode="discover")` for agy/hermes/opencode (lines 708/766/876). **This is a genuine defect I introduced in the Item 2 fix** โ€” a Class A fresh-spawn resume leaves the row's discovery watermark stuck at the original `create_session.sh` timestamp, so `reconcile.sh`'s next sweep could pin an unrelated, older on-disk conversation (created any time after that original watermark, including *before* the resume even happened) to the freshly-resumed, still-silent agent. **One precision correction to the challenge's own proposed diff**: `session_id_source`/`session_id_verified` are not currently set on `agy`/`hermes`/`opencode` rows at all โ€” `create_session.sh`'s per-agent block only assigns those two fields for `claude`/`grok` (see `create_session.sh:402-437`). Setting them on a Class A row in the proposed fix is therefore not "resetting" pre-existing state โ€” it introduces a field that wasn't there before. This is harmless (nothing currently reads `session_id_source`/`session_id_verified` on a non-claude/grok row; `verify_session.py:99`'s escape hatch is gated on `session_id_source == "assigned"`, which a Class A row will never have), but the refined fix below keeps it anyway for forward-consistency with the fields `create_session.sh` already sets for Class B, rather than dropping it โ€” it costs nothing and makes a future reader's mental model ("every row always carries these two fields") uniformly true. ### Refined fix In `.agents/skills/multi-agent-mux-resume/scripts/update_yaml_resumed.sh`, inside the shared post-branch code (i.e. *after* line 165's `target['status'] = 'running'`, applying uniformly regardless of which branch โ€” new-row or existing-row โ€” populated `target`, and reusing the `epoch`/`now` locals already computed at lines 117-118), add: ```python if not uuid: # ISSUE-3 (challenge c4b0a075): a Class A fresh-spawn resume re-arms # discovery from scratch - the resumed agent has not spoken yet, so any # on-disk conversation older than THIS resume must not be auto-pinned to # it by reconcile.sh's mode="discover" mtime >= epoch check. Without this, # the row's watermark stays at the original create_session.sh timestamp, # letting reconcile.sh capture a stale/unrelated transcript before the # resumed agent's first real turn. target['herdr_session_epoch'] = epoch target['herdr_session_created_at'] = now target['session_id_source'] = 'pending-discovery' target['session_id_verified'] = False ``` Gated strictly on `if not uuid:` (the same condition already used at line 175 for the `last_visible_status` branch), so it fires *only* on the new Class A fresh-spawn path from this same job's Item 2 fix โ€” it can never touch a normal resume (`uuid` non-empty, any agent) or any Class B path, since `claude`/`grok` never reach `update_yaml_resumed.sh` with an empty `--uuid` (per Item 2's `resume_session.sh` branch, only `agy`/`hermes`/`opencode` set `FRESH_SPAWN=1`). ### New test spec `tests/test_uuid_target.py` โ€” extend `test_t14_resume_class_a_fresh_spawn_fallback`'s fixture (or add a sibling `test_t16_resume_class_a_fresh_spawn_resets_discovery_epoch`): after creating+stopping the `agy` session at 0 turns, seed a conversation artifact under the workspace with an `mtime` *between* the original creation time and "now" (simulating the challenge's $T_2$ stale transcript), run `resume_session.sh` (not `--dry-run`, since the epoch write only happens in `update_yaml_resumed.sh`'s real YAML mutation), then assert in `agent-sessions.yaml` that `herdr_session_epoch` was advanced to at or after the resume's own timestamp (not the stale creation-time value) โ€” i.e. the pre-existing stale artifact's `mtime` is now `<` the row's watermark and would be correctly rejected by a subsequent `reconcile.sh` discover-mode sweep. Should be verified fail-old/pass-new like the other two Item 2 tests: written against the pre-refinement code, `herdr_session_epoch` stays at the original creation epoch and the assertion fails; after applying the refined fix, it advances and the test passes. ### Updated action list for re-implementation 1. Apply the `if not uuid:` epoch/watermark-reset block above to `update_yaml_resumed.sh`, placed after the shared `target['status'] = 'running'` line so it applies regardless of which branch (new-row vs. existing-row) populated `target`. 2. Add the new regression test per the spec above, and verify fail-old/pass-new for it specifically (not just that it passes). 3. Re-run the full test suite and confirm no regression against the 454-passed baseline from the original `a9c8d6d3` implementation. 4. No changes needed to Item 1, `resume_session.sh`'s branch logic, `verify_session.py`, `workspace_uuid.py`, or any Class B (`claude`/`grok`) code path โ€” this refinement is fully contained within `update_yaml_resumed.sh`'s shared post-branch section.