# 📋 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.
