Files
multi-agent-mux/.agents/reports/planner-reviewer-claude-01/plan-a9c8d6d3.md
T
Godopu 94f2e213d2 docs(reports): archive Issue #3 analysis, implementation plan, and multi-agent peer review reports
- Add Rev. 3 technical analysis report from creator-agy-01
- Add implementation plan from planner-reviewer-claude-01
- Add peer review reports across analysis and implementation review loops (Claude, Grok, OpenCode)
2026-08-31 10:30:46 +09:00

13 KiB

📋 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:

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.