feat(issue-21): resume only at the original cwd; persist the control-plane binding on the already-running path; fail loudly when a worktree binding cannot be computed

- A11: resume refuses (exit 2) a --workspace other than the row's pane.cwd, and
  a pane.cwd that no longer exists (checked first: mam_abs_workspace returns a
  missing path unchanged). Before, claude/grok failed with a misleading "No
  saved session" and class A agents started a new conversation in the wrong
  directory. Rows without pane.cwd are not checked.
- A12 (recommendation 1): create and resume no longer swallow a failed binding
  computation. A worktree (--workspace != control root) is refused with exit 2
  and nothing spawned or recorded; the control root itself warns and records
  the row without control_plane.
- A13 (recommendation 2): resume computes the binding before the herdr-alive
  check, so the already-running path records it too (update_yaml_resumed.sh
  fills a missing field only). Pane forwarding and the whitespace refusal stay
  on the spawn path.

Tests W7-W9.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017R3Eg7MEpSWnm1oB9ZPP6G
This commit is contained in:
GodopuandClaude Opus 5 committed 2026-09-14 15:24:10 +00:00
1 parent fadd38cf56
commit 2679ddfffd
3 files changed
+138 -15

No files matched your search

@@ -47,6 +47,57 @@ case "$AGENT" in
esac
[ -n "$SESSION_NAME" ] || { echo "ERROR: --session required" >&2; exit 2; }
# Issue #21 phase 2 (A11): a session resumes only in the directory it ran in
# (its conversation is keyed by that cwd; a class A agent would otherwise start
# a fresh conversation in the wrong tree). Existence first: mam_abs_workspace
# returns a missing path unchanged, which would read as "a different path".
ROW_CWD="$(MAM_STATE_JSON="$(load_state_json)" SESSION_NAME="$SESSION_NAME" python3 -c '
import json, os
d = json.loads(os.environ.get("MAM_STATE_JSON") or "{}")
for s in d.get("herdr_sessions", []):
if s.get("name") == os.environ["SESSION_NAME"]:
print((s.get("pane") or {}).get("cwd", "") or ""); break
' 2>/dev/null || true)"
if [ -n "$ROW_CWD" ]; then
if [ ! -d "$ROW_CWD" ]; then
echo "ERROR: session '$SESSION_NAME' ran in '$ROW_CWD', which no longer exists (worktree removed?). It cannot be resumed; recreate the worktree at that path, or create a new session." >&2
exit 2
fi
if [ "$(mam_abs_workspace "$ROW_CWD")" != "$(mam_abs_workspace "$WORKSPACE")" ]; then
echo "ERROR: session '$SESSION_NAME' was created in '$ROW_CWD'; resume it with --workspace '$ROW_CWD' (its conversation is keyed by that directory)." >&2
exit 2
fi
fi
# Issue #21 (item 3, D11): the control-plane binding recorded on the row wins
# over the caller's environment (a difference is reported on stderr); a legacy
# row without one uses the caller's binding, which update_yaml_resumed.sh then
# records - on the already-running path too (phase 2, A13). A binding that
# cannot be computed is refused for a worktree and warned about for the
# control root itself (A12), as in create.
ROW_CP="$(MAM_STATE_JSON="$(load_state_json)" SESSION_NAME="$SESSION_NAME" python3 -c "
import json, os
d = json.loads(os.environ.get('MAM_STATE_JSON') or '{}')
for s in d.get('herdr_sessions', []):
if s.get('name') == os.environ['SESSION_NAME'] and isinstance(s.get('control_plane'), dict):
print(json.dumps(s['control_plane']))
break
" 2>/dev/null || true)"
CP_ROOT="$(printf '%s' "$ROW_CP" | python3 -c 'import json,sys
try: print(json.load(sys.stdin).get("workspace_root") or "")
except Exception: print("")' 2>/dev/null || true)"
CP_ROOT="${CP_ROOT:-$WORKSPACE_ROOT}"
cp_rc=0
CP_JSON="$(mam_control_plane_json "$WORKSPACE" "$ROW_CP")" || cp_rc=$?
if [ "$cp_rc" -ne 0 ] || [ -z "$CP_JSON" ]; then
if [ "$(mam_abs_workspace "$WORKSPACE")" != "$(mam_abs_workspace "$CP_ROOT")" ]; then
echo "ERROR: cannot compute the control-plane binding (rc=$cp_rc) for --workspace '$WORKSPACE' (control root '$CP_ROOT'); refusing to resume a session that would use the wrong control plane" >&2
exit 2
fi
echo "WARNING: control-plane binding unavailable (rc=$cp_rc); the row is kept without control_plane" >&2
CP_JSON=""
fi
# 1. Resolve the session id
UUID=$(bash "$(dirname "${BASH_SOURCE[0]}")/resolve_session_id.sh" \
--workspace "$WORKSPACE" --agent "$AGENT" --session "$SESSION_NAME")
@@ -91,7 +142,7 @@ if herdr has-session -t "$SESSION_NAME" 2>/dev/null; then
fi
echo "herdr '$SESSION_NAME' already running."
# Just update YAML to make sure it's set to running
bash "$(dirname "${BASH_SOURCE[0]}")/update_yaml_resumed.sh" \
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" \
${HERDR_WORKSPACE_OPT:+--herdr-workspace "$HERDR_WORKSPACE_OPT"}
@@ -150,19 +201,8 @@ else
fi
fi
# Issue #21 (item 3, D11): the control-plane binding recorded on the row wins
# over the caller's environment (a difference is reported on stderr); a legacy
# row without one uses the caller's binding, which update_yaml_resumed.sh then
# records. Forwarded to the pane only when it differs from --workspace's own.
ROW_CP="$(MAM_STATE_JSON="$(load_state_json)" SESSION_NAME="$SESSION_NAME" python3 -c "
import json, os
d = json.loads(os.environ.get('MAM_STATE_JSON') or '{}')
for s in d.get('herdr_sessions', []):
if s.get('name') == os.environ['SESSION_NAME'] and isinstance(s.get('control_plane'), dict):
print(json.dumps(s['control_plane']))
break
" 2>/dev/null || true)"
CP_JSON="$(mam_control_plane_json "$WORKSPACE" "$ROW_CP" || true)"
# The binding (computed above) is forwarded to the pane only when it differs
# from --workspace's own; a forward with whitespace is refused (spawn path only).
CP_PREFIX=""
if [ -n "$CP_JSON" ]; then
cp_rc=0