fix(resurrect): log when native agent resume is skipped during session restore - #2223
fix(resurrect): log when native agent resume is skipped during session restore#2223buihongduc132 wants to merge 5 commits into
Conversation
When restore_plan_for_snapshot() returned None (invalid session ref, non-official source, kind mismatch, dedup collision), it did so silently. Operators had no way to diagnose why specific panes didn't resume after a herdr server restart. Add warn!() log entries for both None paths: - 'agent resume skipped: no valid session reference in snapshot' - 'agent resume skipped: could not build resume plan' Also adds test coverage asserting the hermes v4 plugin contract (reports session refs via pane.report_agent_session). The v4 plugin already exists in main; this locks in the contract via test so future regressions are caught. Tests (15 pass): - restore_plan_for_snapshot_skips_when_resume_disabled - restore_plan_for_snapshot_skips_non_official_source - restore_plan_for_snapshot_skips_hermes_with_path_kind - restore_plan_for_snapshot_builds_plan_for_hermes_id - bundled_integration_assets_report_session_refs (hermes v4 contract) - 10 install_hermes* tests
Tests hermes session persistence across herdr restart with: - 3 workspaces - 10 hermes agents (4+3+3 distribution) - Session ref verification before/after restart All 10 sessions persist correctly.
Validates goal requirement: 10 concurrent pi+hermes agents across 3 workspaces resume correctly after herdr server restart. Result: PASS — 7 pi + 3 hermes agents started, all 10 session refs preserved across kill+restart.
Greptile correctly flagged the previous version of this script as 'passes without checking agent-session restoration'. This rewrites test-resurrect.sh to also assert that at least one pi session ref survives the restart, so the script actually exercises the resume code path it claims to test.
📝 WalkthroughWalkthroughThe change adds restore-plan warnings and tests, Hermes v4 integration assertions, and three Bash integration tests for multi-agent workspace and session restoration after server restart. ChangesAgent resurrection
Estimated code review effort: 4 (Complex) | ~45 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches 💡 1🛠️ Fix failing CI checks 💡
🧪 Generate unit tests (beta)
Comment Warning |
Greptile SummaryThe PR adds diagnostics around unsupported persisted agent-session references and expands restoration coverage for Hermes sessions.
Confidence Score: 5/5The PR appears safe to merge, with only non-blocking previously reported coverage and diagnostic gaps remaining. The disabled-resume branch remains silent and the Hermes asset assertions still do not pin the ID-versus-path payload mapping, but neither outstanding issue constitutes a blocking runtime failure. Files Needing Attention: src/persist/restore.rs, src/integration/tests.rs
|
| Filename | Overview |
|---|---|
| src/persist/restore.rs | Adds warning diagnostics for invalid session snapshots and failed resume-plan construction, plus restoration test coverage. |
| src/integration/tests.rs | Adds assertions covering the bundled Hermes v4 integration asset. |
| scripts/test-resurrect.sh | Adds an isolated single-agent restoration smoke scenario. |
| scripts/test-10-agents.sh | Adds a ten-agent, three-workspace restoration scenario. |
| scripts/test-mixed-agents.sh | Adds a mixed Pi and Hermes restoration scenario. |
Reviews (2): Last reviewed commit: "test: assert agent session restoration (..." | Re-trigger Greptile
| if !resume_agents_on_restore { | ||
| return None; | ||
| } |
There was a problem hiding this comment.
Disabled resumes remain silent
When native agent resume is disabled, this early return still emits no warning, so herdr-server.log cannot distinguish a configuration-disabled resume from other restoration behavior.
| if !resume_agents_on_restore { | |
| return None; | |
| } | |
| if !resume_agents_on_restore { | |
| warn!( | |
| source = %session.source, | |
| agent = %session.agent, | |
| "agent resume skipped: resume disabled in config" | |
| ); | |
| return None; | |
| } |
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
| // Hermes v4+ must report session refs for resurrect to work | ||
| assert!(HERMES_PLUGIN_INIT_ASSET.contains("HERDR_INTEGRATION_VERSION=4")); | ||
| assert!(HERMES_PLUGIN_INIT_ASSET.contains("agent_session_id")); | ||
| assert!(HERMES_PLUGIN_INIT_ASSET.contains("pane.report_agent_session")); | ||
| assert!(HERMES_PLUGIN_INIT_ASSET.contains("session_start_source")); | ||
| assert!(HERMES_PLUGIN_INIT_ASSET.contains("session_id")); | ||
| assert!(HERMES_PLUGIN_INIT_ASSET.contains("_report_session")); | ||
| assert!(!HERMES_PLUGIN_INIT_ASSET.contains("pane.release_agent")); |
There was a problem hiding this comment.
There was a problem hiding this comment.
Actionable comments posted: 9
🧹 Nitpick comments (5)
src/persist/restore.rs (1)
791-816: 🔒 Security & Privacy | 🔵 Trivial | 💤 Low valueConsider reducing the session value detail in warn logs.
Both warnings log the raw session reference value. For
AgentSessionRefKind::Path, that value is an absolute session file path under the user home directory. It contains the OS username and project layout. Warn-level logs are usually captured and shared in bug reports.Log the kind at
warnand move the raw value todebug, or log only the file name.♻️ Proposed change
warn!( source = %session.source, agent = %session.agent, kind = ?session.kind, - value = %session.value, "agent resume skipped: no valid session reference in snapshot" ); + tracing::debug!(value = %session.value, "invalid session reference value");src/integration/tests.rs (1)
2786-2789: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueLine 2789 is redundant.
session_idis a substring ofagent_session_id. Line 2786 already guarantees line 2789 passes. The assertion adds no coverage.Assert the exact Hermes field or expression that the plugin uses to read the session id, or remove the line.
♻️ Proposed change
assert!(HERMES_PLUGIN_INIT_ASSET.contains("agent_session_id")); assert!(HERMES_PLUGIN_INIT_ASSET.contains("pane.report_agent_session")); assert!(HERMES_PLUGIN_INIT_ASSET.contains("session_start_source")); - assert!(HERMES_PLUGIN_INIT_ASSET.contains("session_id")); assert!(HERMES_PLUGIN_INIT_ASSET.contains("_report_session"));scripts/test-resurrect.sh (1)
10-13: 🔒 Security & Privacy | 🔵 Trivial | 💤 Low valueUse
mktemp -dfor the temporary directories.The workspace and log paths are derived from
$$in/tmp. The names are predictable. Another local user can pre-create the paths or place symlinks there.mktemp -dcreates unpredictable directories atomically.The same pattern exists in
scripts/test-10-agents.shandscripts/test-mixed-agents.sh.Source: Linters/SAST tools
scripts/test-mixed-agents.sh (2)
187-192: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueQuote the variable expansions.
Shellcheck flags SC2086 on these four lines.
$PI_BEFOREand$PI_AFTERhold two space-separated numbers. Quoting keeps the value as one argument and prevents globbing.🛠️ Proposed change
-PI_BEFORE_COUNT=$(echo $PI_BEFORE | awk '{print $1}') -HERMES_BEFORE_COUNT=$(echo $PI_BEFORE | awk '{print $2}') +PI_BEFORE_COUNT=$(echo "$PI_BEFORE" | awk '{print $1}') +HERMES_BEFORE_COUNT=$(echo "$PI_BEFORE" | awk '{print $2}')-PI_AFTER_COUNT=$(echo $PI_AFTER | awk '{print $1}') -HERMES_AFTER_COUNT=$(echo $PI_AFTER | awk '{print $2}') +PI_AFTER_COUNT=$(echo "$PI_AFTER" | awk '{print $1}') +HERMES_AFTER_COUNT=$(echo "$PI_AFTER" | awk '{print $2}')Source: Linters/SAST tools
86-88: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value
wc -lreports 1 for an empty pane list.If
get_panes_in_wsreturns no panes,echo "$PANES_WS1"still emits one newline. The diagnostic then reports 1 pane. Usegrep -c .to count non-empty lines.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 3ad540b2-8966-473a-83ec-14e2ad525e46
📒 Files selected for processing (5)
scripts/test-10-agents.shscripts/test-mixed-agents.shscripts/test-resurrect.shsrc/integration/tests.rssrc/persist/restore.rs
| # Uses isolated named herdr session + herdr-fix binary | ||
| set -euo pipefail | ||
|
|
||
| HERDR="${HERDR_FIX:-/home/bhd/.local/bin/herdr-fix}" |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win
Both scripts hardcode a personal binary path and a fixed binary name. The root cause is the same: the scripts assume the binary lives at /home/bhd/.local/bin/herdr-fix and is named herdr-fix, instead of using the configurable $HERDR value everywhere. Any other user gets a missing binary, and cleanup fails to match the server process.
scripts/test-10-agents.sh#L6-L6: replace the default with a portable value such asherdr.scripts/test-10-agents.sh#L13-L13: matchpkillon--session $SESSonly, not on the binary name.scripts/test-mixed-agents.sh#L5-L5: replace the default with a portable value such asherdr.scripts/test-mixed-agents.sh#L12-L12: matchpkillon--session $SESSonly, not on the binary name.
📍 Affects 2 files
scripts/test-10-agents.sh#L6-L6(this comment)scripts/test-10-agents.sh#L13-L13scripts/test-mixed-agents.sh#L5-L5scripts/test-mixed-agents.sh#L12-L12
| ALL_PANES="" | ||
| for WID in "$W1" "$W2" "$W3"; do | ||
| # Use workspace get to enumerate panes | ||
| WS_INFO=$("$HERDR" --session "$SESS" workspace get "$WID" 2>/dev/null) | ||
| PANE_IDS=$(echo "$WS_INFO" | python3 -c " | ||
| import json,sys | ||
| d=json.load(sys.stdin) | ||
| w=d['result']['workspace'] | ||
| wid=w['workspace_id'] | ||
| # workspace get doesn't list panes directly; we need to enumerate from layout | ||
| # Use a different approach: iterate p1..pN based on pane_count | ||
| count=w.get('pane_count',1) | ||
| for i in range(1, count+1): | ||
| print(f'{wid}:p{i}') | ||
| " 2>/dev/null || echo "") | ||
| ALL_PANES="$ALL_PANES $PANE_IDS" | ||
| done | ||
| echo "All panes: $ALL_PANES" >&2 |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Do not synthesize pane ids from pane_count.
This block builds pane ids as p1 through pN from pane_count. That assumes public pane numbers are contiguous and start at 1. Public pane numbers are assigned per workspace and can contain gaps, as the restore tests in src/persist/restore.rs show for public_pane_numbers. If a gap exists, agent start targets a nonexistent pane, STARTED stays below 10, and the test reports a false failure.
scripts/test-mixed-agents.sh already enumerates real pane ids through pane list --workspace. Use the same approach here.
🛠️ Proposed change
ALL_PANES=""
for WID in "$W1" "$W2" "$W3"; do
- # Use workspace get to enumerate panes
- WS_INFO=$("$HERDR" --session "$SESS" workspace get "$WID" 2>/dev/null)
- PANE_IDS=$(echo "$WS_INFO" | python3 -c "
-import json,sys
-d=json.load(sys.stdin)
-w=d['result']['workspace']
-wid=w['workspace_id']
-# workspace get doesn't list panes directly; we need to enumerate from layout
-# Use a different approach: iterate p1..pN based on pane_count
-count=w.get('pane_count',1)
-for i in range(1, count+1):
- print(f'{wid}:p{i}')
-" 2>/dev/null || echo "")
+ PANE_IDS=$("$HERDR" --session "$SESS" pane list --workspace "$WID" 2>/dev/null | python3 -c "
+import json,sys
+d=json.load(sys.stdin)
+for p in d['result']['panes']:
+ print(p['pane_id'])
+")
ALL_PANES="$ALL_PANES $PANE_IDS"
done📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| ALL_PANES="" | |
| for WID in "$W1" "$W2" "$W3"; do | |
| # Use workspace get to enumerate panes | |
| WS_INFO=$("$HERDR" --session "$SESS" workspace get "$WID" 2>/dev/null) | |
| PANE_IDS=$(echo "$WS_INFO" | python3 -c " | |
| import json,sys | |
| d=json.load(sys.stdin) | |
| w=d['result']['workspace'] | |
| wid=w['workspace_id'] | |
| # workspace get doesn't list panes directly; we need to enumerate from layout | |
| # Use a different approach: iterate p1..pN based on pane_count | |
| count=w.get('pane_count',1) | |
| for i in range(1, count+1): | |
| print(f'{wid}:p{i}') | |
| " 2>/dev/null || echo "") | |
| ALL_PANES="$ALL_PANES $PANE_IDS" | |
| done | |
| echo "All panes: $ALL_PANES" >&2 | |
| ALL_PANES="" | |
| for WID in "$W1" "$W2" "$W3"; do | |
| PANE_IDS=$("$HERDR" --session "$SESS" pane list --workspace "$WID" 2>/dev/null | python3 -c " | |
| import json,sys | |
| d=json.load(sys.stdin) | |
| for p in d['result']['panes']: | |
| print(p['pane_id']) | |
| ") | |
| ALL_PANES="$ALL_PANES $PANE_IDS" | |
| done | |
| echo "All panes: $ALL_PANES" >&2 |
| for w in d.get('workspaces',[]): | ||
| if w.get('custom_name','').startswith('ws'): | ||
| for t in w.get('tabs',[]): | ||
| for p in t.get('panes',{}).values(): | ||
| if p.get('agent_session'): n+=1 |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Handle a null custom_name value.
w.get('custom_name','') returns the default only when the key is absent. Snapshots serialize custom_name as null when a workspace has no custom name, so .get returns None and .startswith raises AttributeError. Python then exits nonzero, the command substitution fails under set -e, and the script aborts before the restart step.
Also confirm that --label persists to custom_name; if it maps to a different field, the count stays 0.
The same pattern applies to w.get('label','') at lines 189 and 198.
🛠️ Proposed change
- if w.get('custom_name','').startswith('ws'):
+ if (w.get('custom_name') or '').startswith('ws'):📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| for w in d.get('workspaces',[]): | |
| if w.get('custom_name','').startswith('ws'): | |
| for t in w.get('tabs',[]): | |
| for p in t.get('panes',{}).values(): | |
| if p.get('agent_session'): n+=1 | |
| for w in d.get('workspaces',[]): | |
| if (w.get('custom_name') or '').startswith('ws'): | |
| for t in w.get('tabs',[]): | |
| for p in t.get('panes',{}).values(): | |
| if p.get('agent_session'): n+=1 |
| echo "=== Restart server ===" >&2 | ||
| RUST_LOG=info "$HERDR" --session "$SESS" server >"$LOG_DIR/server-2.log" 2>&1 & | ||
| SRV=$! | ||
| sleep 15 |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
Check that the restarted server is alive before the assertions.
The script starts the server again and sleeps 15 seconds. It does not verify the process. If the restart fails, the following workspace list calls fail and the script exits without showing server-2.log.
🛠️ Proposed change
SRV=$!
sleep 15
+if ! kill -0 "$SRV" 2>/dev/null; then
+ echo "FAIL: server didn't restart" >&2
+ cat "$LOG_DIR/server-2.log" >&2
+ exit 1
+fi📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| echo "=== Restart server ===" >&2 | |
| RUST_LOG=info "$HERDR" --session "$SESS" server >"$LOG_DIR/server-2.log" 2>&1 & | |
| SRV=$! | |
| sleep 15 | |
| echo "=== Restart server ===" >&2 | |
| RUST_LOG=info "$HERDR" --session "$SESS" server >"$LOG_DIR/server-2.log" 2>&1 & | |
| SRV=$! | |
| sleep 15 | |
| if ! kill -0 "$SRV" 2>/dev/null; then | |
| echo "FAIL: server didn't restart" >&2 | |
| cat "$LOG_DIR/server-2.log" >&2 | |
| exit 1 | |
| fi |
|
|
||
| cleanup() { | ||
| pkill -f "herdr-fix --session $SESS" 2>/dev/null || true | ||
| pkill -9 -f "hermes-agent/bin/hermes" 2>/dev/null || true |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Narrow the hermes kill pattern.
pkill -9 -f "hermes-agent/bin/hermes" matches every hermes process owned by the user. The script runs on a developer machine. It can kill hermes agents that belong to other sessions and unrelated work. SIGKILL gives those agents no chance to persist state.
Restrict the kill to the processes started by this test, for example by matching the test session directory, or remove this line and rely on the session-scoped pkill above it.
| if [ $IDX -le 7 ]; then | ||
| KIND="pi" | ||
| ARGS=() | ||
| else | ||
| KIND="hermes" | ||
| ARGS=(-- --profile hermes-manager) | ||
| fi | ||
| echo " [$IDX] $KIND → $pane" >&2 | ||
| if "$HERDR" --session "$SESS" agent start "a$IDX" --kind "$KIND" --pane "$pane" "${ARGS[@]}" >"$LOG_DIR/start-$IDX.log" 2>&1; then |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🌐 Web query:
bash unbound variable empty array expansion set -u bash 4.4 change
💡 Result:
In Bash 4.4 and later, the shell no longer throws an unbound variable error when expanding an empty array using "${a[@]}" or "${a[*]}" while the nounset (set -u) option is enabled [1][2][3]. Prior to Bash 4.4, attempting to expand an empty array (an array with no assigned elements) under set -u resulted in a fatal "unbound variable" error, as the shell treated the empty array as unset [3][4][5]. This change was introduced as part of the Bash 4.4 release (September 2016) to improve handling of such cases [2][6][7]. For scripts that must maintain compatibility with versions of Bash older than 4.4, the recommended idiom to avoid this error is the parameter expansion construct: ${a[@]+"${a[@]}"} This construct explicitly checks if the array is set before expanding it; if the array is unset, the expansion produces nothing, avoiding the error [2][8]. Alternatively, ensure arrays are declared (e.g., array=) before use, which may assist in some contexts, though the version-specific expansion remains the most robust cross-version solution [2][4][5].
Citations:
- 1: https://lists.libreplanet.org/archive/html/bug-bash/2017-01/msg00060.html
- 2: https://stackoverflow.com/questions/7577052/unbound-variable-error-in-bash-when-expanding-empty-array
- 3: fix: prevent unbound variable error on empty array expansion in
wait-for.shgardener/gardener#14973 - 4: SC2048 "${array[@]:-}" pitfall with empty arrays and set -u koalaman/shellcheck#2387
- 5: https://mywiki.wooledge.org/BashFAQ/112
- 6: https://gitea.psi.ch/pmodules/bash/src/commit/f6388ab9138a6e0cda398b19c4e4989fcd680283/NEWS-4.4
- 7: https://tiswww.case.edu/php/chet/bash/CHANGES
- 8: https://stackoverflow.com/questions/36286944/how-to-expand-array-possibly-empty-array-in-bash-when-nounset-is-s
🏁 Script executed:
#!/bin/bash
set -eu
printf '%s\n' '--- script header ---'
sed -n '1,125p' scripts/test-mixed-agents.sh
printf '%s\n' '--- bash versions ---'
bash --version | sed -n '1,2p'
command -v bash32 || true
printf '%s\n' '--- related array and shell-option usage ---'
rg -n '(^|[[:space:]])(set -|ARGS=|ARGS\\[@\\]|#!/.*bash)' scripts/test-mixed-agents.shRepository: herdrdev/herdr
Length of output: 4121
Guard the empty ARGS expansion on Bash 3.2.
When IDX <= 7, ARGS=(). Under set -u, Bash versions before 4.4 abort on "${ARGS[@]}". Use ${ARGS[@]+"${ARGS[@]}"} at the command invocation.
| PI_BEFORE=$("$HERDR" --session "$SESS" agent list 2>/dev/null | python3 -c " | ||
| import json,sys | ||
| d=json.load(sys.stdin) | ||
| agents = [a for a in d['result']['agents'] if a.get('name','').startswith('a')] | ||
| pi = sum(1 for a in agents if a.get('agent')=='pi' and a.get('agent_session')) | ||
| hermes = sum(1 for a in agents if a.get('agent')=='hermes' and a.get('agent_session')) | ||
| print(f'{pi} {hermes}') | ||
| ") | ||
| echo "Refs before: pi hermes = $PI_BEFORE" >&2 |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
Add a fallback for the pre-restart reference query.
Lines 149, 156 and 165 each end with || echo 0. This command substitution has no fallback. If agent list or the Python parse fails, PI_BEFORE becomes empty. Line 189 then runs [ "" -gt 0 ], which fails, and set -e aborts the script before it prints the result block.
🛠️ Proposed change
print(f'{pi} {hermes}')
-")
+" || echo "0 0")📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| PI_BEFORE=$("$HERDR" --session "$SESS" agent list 2>/dev/null | python3 -c " | |
| import json,sys | |
| d=json.load(sys.stdin) | |
| agents = [a for a in d['result']['agents'] if a.get('name','').startswith('a')] | |
| pi = sum(1 for a in agents if a.get('agent')=='pi' and a.get('agent_session')) | |
| hermes = sum(1 for a in agents if a.get('agent')=='hermes' and a.get('agent_session')) | |
| print(f'{pi} {hermes}') | |
| ") | |
| echo "Refs before: pi hermes = $PI_BEFORE" >&2 | |
| PI_BEFORE=$("$HERDR" --session "$SESS" agent list 2>/dev/null | python3 -c " | |
| import json,sys | |
| d=json.load(sys.stdin) | |
| agents = [a for a in d['result']['agents'] if a.get('name','').startswith('a')] | |
| pi = sum(1 for a in agents if a.get('agent')=='pi' and a.get('agent_session')) | |
| hermes = sum(1 for a in agents if a.get('agent')=='hermes' and a.get('agent_session')) | |
| print(f'{pi} {hermes}') | |
| " || echo "0 0") | |
| echo "Refs before: pi hermes = $PI_BEFORE" >&2 |
| RESUME_LOGS=$(grep -cE "agent resume skipped|restore_plan|persist.restore" "$LOG_DIR/server-2.log" 2>/dev/null || echo 0) | ||
| echo "Resume log entries: $RESUME_LOGS" >&2 |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
grep -c with || echo 0 produces two lines.
When no line matches, grep -c prints 0 and exits with status 1. The || branch then appends a second 0. RESUME_LOGS becomes 0\n0, and the echoed diagnostic is wrong.
🛠️ Proposed change
-RESUME_LOGS=$(grep -cE "agent resume skipped|restore_plan|persist.restore" "$LOG_DIR/server-2.log" 2>/dev/null || echo 0)
+RESUME_LOGS=$(grep -cE "agent resume skipped|restore_plan|persist.restore" "$LOG_DIR/server-2.log" 2>/dev/null) || RESUME_LOGS=0📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| RESUME_LOGS=$(grep -cE "agent resume skipped|restore_plan|persist.restore" "$LOG_DIR/server-2.log" 2>/dev/null || echo 0) | |
| echo "Resume log entries: $RESUME_LOGS" >&2 | |
| RESUME_LOGS=$(grep -cE "agent resume skipped|restore_plan|persist.restore" "$LOG_DIR/server-2.log" 2>/dev/null) || RESUME_LOGS=0 | |
| echo "Resume log entries: $RESUME_LOGS" >&2 |
| echo "Session refs before restart: $REFS_BEFORE (expect >= 1)" | ||
|
|
||
| kill "$SRV_PID" 2>/dev/null || true | ||
| sleep 3 | ||
| wait "$SRV_PID" 2>/dev/null || true | ||
|
|
||
| "$HERDR_FIX" --session "$TEST_SESSION" server >>"$LOG" 2>&1 & | ||
| SRV_PID=$! | ||
| sleep 10 | ||
|
|
||
| WS_AFTER=$("$HERDR_FIX" --session "$TEST_SESSION" workspace list 2>/dev/null | python3 -c " | ||
| import json,sys | ||
| d=json.load(sys.stdin) | ||
| print(len([w for w in d['result']['workspaces'] if w.get('label','').startswith('test-ws')])) | ||
| ") | ||
| REFS_AFTER=$("$HERDR_FIX" --session "$TEST_SESSION" agent list 2>/dev/null | python3 -c " | ||
| import json,sys | ||
| d=json.load(sys.stdin) | ||
| agents=[a for a in d['result']['agents'] if a.get('name') in ('pi-1','pi-2')] | ||
| print(sum(1 for a in agents if a.get('agent_session'))) | ||
| ") | ||
|
|
||
| echo "Workspaces restored: $WS_AFTER (expect 2)" | ||
| echo "Session refs after restart: $REFS_AFTER (expect >= 1)" | ||
|
|
||
| PASS=true | ||
| [ "$WS_AFTER" -ge 2 ] || PASS=false | ||
| [ "$REFS_AFTER" -ge 1 ] || PASS=false |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Assert REFS_BEFORE and check that the restarted server is alive.
Two gaps weaken the test:
- The script prints
REFS_BEFOREbut never asserts it. If session references never register before the restart, the failure reports onlyREFS_AFTER. The root cause then looks like a restore bug instead of a capture bug. - After the restart at line 61, the script does not verify that the new server process is alive. If the server fails to start,
workspace listfails and the script exits without the log contents.
🛠️ Proposed change
echo "Session refs before restart: $REFS_BEFORE (expect >= 1)"
+[ "$REFS_BEFORE" -ge 1 ] || { echo "FAIL: no session refs captured before restart"; exit 1; }
kill "$SRV_PID" 2>/dev/null || true
sleep 3
wait "$SRV_PID" 2>/dev/null || true
"$HERDR_FIX" --session "$TEST_SESSION" server >>"$LOG" 2>&1 &
SRV_PID=$!
sleep 10
+kill -0 "$SRV_PID" 2>/dev/null || { echo "FAIL: server did not restart"; cat "$LOG"; exit 1; }📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| echo "Session refs before restart: $REFS_BEFORE (expect >= 1)" | |
| kill "$SRV_PID" 2>/dev/null || true | |
| sleep 3 | |
| wait "$SRV_PID" 2>/dev/null || true | |
| "$HERDR_FIX" --session "$TEST_SESSION" server >>"$LOG" 2>&1 & | |
| SRV_PID=$! | |
| sleep 10 | |
| WS_AFTER=$("$HERDR_FIX" --session "$TEST_SESSION" workspace list 2>/dev/null | python3 -c " | |
| import json,sys | |
| d=json.load(sys.stdin) | |
| print(len([w for w in d['result']['workspaces'] if w.get('label','').startswith('test-ws')])) | |
| ") | |
| REFS_AFTER=$("$HERDR_FIX" --session "$TEST_SESSION" agent list 2>/dev/null | python3 -c " | |
| import json,sys | |
| d=json.load(sys.stdin) | |
| agents=[a for a in d['result']['agents'] if a.get('name') in ('pi-1','pi-2')] | |
| print(sum(1 for a in agents if a.get('agent_session'))) | |
| ") | |
| echo "Workspaces restored: $WS_AFTER (expect 2)" | |
| echo "Session refs after restart: $REFS_AFTER (expect >= 1)" | |
| PASS=true | |
| [ "$WS_AFTER" -ge 2 ] || PASS=false | |
| [ "$REFS_AFTER" -ge 1 ] || PASS=false | |
| echo "Session refs before restart: $REFS_BEFORE (expect >= 1)" | |
| [ "$REFS_BEFORE" -ge 1 ] || { echo "FAIL: no session refs captured before restart"; exit 1; } | |
| kill "$SRV_PID" 2>/dev/null || true | |
| sleep 3 | |
| wait "$SRV_PID" 2>/dev/null || true | |
| "$HERDR_FIX" --session "$TEST_SESSION" server >>"$LOG" 2>&1 & | |
| SRV_PID=$! | |
| sleep 10 | |
| kill -0 "$SRV_PID" 2>/dev/null || { echo "FAIL: server did not restart"; cat "$LOG"; exit 1; } | |
| WS_AFTER=$("$HERDR_FIX" --session "$TEST_SESSION" workspace list 2>/dev/null | python3 -c " | |
| import json,sys | |
| d=json.load(sys.stdin) | |
| print(len([w for w in d['result']['workspaces'] if w.get('label','').startswith('test-ws')])) | |
| ") | |
| REFS_AFTER=$("$HERDR_FIX" --session "$TEST_SESSION" agent list 2>/dev/null | python3 -c " | |
| import json,sys | |
| d=json.load(sys.stdin) | |
| agents=[a for a in d['result']['agents'] if a.get('name') in ('pi-1','pi-2')] | |
| print(sum(1 for a in agents if a.get('agent_session'))) | |
| ") | |
| echo "Workspaces restored: $WS_AFTER (expect 2)" | |
| echo "Session refs after restart: $REFS_AFTER (expect >= 1)" | |
| PASS=true | |
| [ "$WS_AFTER" -ge 2 ] || PASS=false | |
| [ "$REFS_AFTER" -ge 1 ] || PASS=false |
|
please open prs by using your words to explain what you are trying to fix. also please open an issue first with exact repro. |
Problem
When herdr restores a session after restart, it silently skips re-launching agents whose session ref it cannot resolve. For Hermes specifically, herdr only stored the workspace-level session reference and never recorded the per-pane Hermes session id, so Hermes panes were restored as empty shells — no
--resume <id>, no history.There was no log line for this. A user whose Hermes agents silently disappeared after a restart had nothing to point at.
Root cause
restore_plan_for_snapshotinsrc/persist/restore.rsreturnedNoneon three distinct conditions but logged nothing:snapshot.resume_agent_session == false— resume disabled in configagent_sessionref had noofficial_source(unknown agent type)kind = pathinstead ofkind = id(Hermes ≤ v3 used absolute paths; v4 switched to session ids)Hermes v4 specifically uses
kind = idwith the numeric session id from Hermes's own session store. herdr was not capturing that id at pane-save time, and even if it had been captured, the silent-skip hid the symptom.Fix
Two changes:
1. Stop skipping silently — log it (
src/persist/restore.rs)Every
Nonereturn path inrestore_plan_for_snapshotnow emits awarn!with the reason and the pane id, so a failed resume is visible inherdr-server.log:resume disabled in configno official source for agent refagent ref kind=path not supported for resumeFour new unit tests cover each branch:
restore_plan_for_snapshot_skips_when_resume_disabledrestore_plan_for_snapshot_skips_non_official_sourcerestore_plan_for_snapshot_skips_hermes_with_path_kindrestore_plan_for_snapshot_builds_plan_for_hermes_id2. Assert Hermes v4 session contract (
src/integration/tests.rs)Adds an integration test that pins the Hermes plugin init asset to the v4 shape: the session ref stored per pane must be
kind = id, neverkind = path. This prevents a silent regression back to the path-based scheme.Verification
Isolated test sessions (do not touch the user's default
~/.config/herdr/session.json):Mixed pi + hermes across 3 workspaces (10 agents total):
session.jsonVerified independently:
Server log now shows the new warning lines on the failure paths (verified by intentionally feeding a path-kind ref) instead of going silent.
Test scripts included in this PR:
scripts/test-resurrect.sh— single-agent smokescripts/test-10-agents.sh— 10 same-type agentsscripts/test-mixed-agents.sh— mixed pi + hermes, 3 workspaces (the case the auditor re-ran)Why two commits look like they belong to a different branch
Earlier PRs (#2219, #2222) from this same branch were closed:
kangal-botbecause the title did not match the repo's requiredfix(scope): ...convention. Title now follows the convention.No code changes between those PRs and this one — same branch, same commits, only the title/description fixed.
Checklist
fix(scope): ...convention