Skip to content

fix: recover Hermes from stale nested tx9-logs mirror FDs - #27

Merged
bmdavis419 merged 3 commits into
mainfrom
cursor/hb-hermes-gateway-recovery-2626
Aug 14, 2026
Merged

fix: recover Hermes from stale nested tx9-logs mirror FDs#27
bmdavis419 merged 3 commits into
mainfrom
cursor/hb-hermes-gateway-recovery-2626

Conversation

@bmdavis419

@bmdavis419 bmdavis419 commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

The boot hb-workload loop could stay alive while the Hermes gateway stayed down. Nested tx9-logs capture trusted inherited TX9_LOG_MIRROR_*_FD values even when those descriptors were closed or reused, crashed with EBADF before exec, and the Hermes wrapper had no --restart-delay. Login-shell hb up masked it because that path does not inherit the outer capture environment.

This ports the local mitigation into the source package:

  • hb unsets the mirror-FD pair after closing lock descriptors if either member is missing, nonnumeric, or closed
  • the Hermes wrapper runs with --restart-delay (default 2s, overridable via HB_GATEWAY_RESTART_DELAY) and hermes gateway run --replace --external-supervisor
  • wrapper stderr is kept in hermes-gateway.launcher.log; a wrapper that dies before Hermes exec reports that exit status
  • hb gateway-disable / hb pause wait for the wrapper as well as the child, so the restart loop cannot defeat an intentional stop
  • tx9-logs falls back to owned stdout/stderr dups instead of crashing on invalid inherited FDs, including oversized values that raise OverflowError
  • pending gateway reload requests remain queued while the supervisor is between child processes, closing the Bugbot review race

Regression coverage includes absent/malformed/half-valid/closed mirror FDs, nested outer-capture → hb → inner-capture, unexpected child-exit recovery, disable/pause non-restart, and reload-request preservation during supervised restart.

Validation: make syntax lint test passes locally. CI runs the complete make check, including Go 1.26 vet/build/tests.

Open in Web Open in Cursor 

Note

Recover Hermes from stale nested tx9-logs mirror FDs by supervising restart

  • Launches Hermes under a tx9-logs capture wrapper with --restart-delay and --external-supervisor, so the supervisor can restart crashed children without interfering with intentional stops.
  • Adds _sanitize_log_mirror_fds to drop TX9_LOG_MIRROR_STDOUT_FD/STDERR_FD env vars if they reference closed or invalid FDs before spawning, and inherited_open_fd in tx9-logs to validate and fall back gracefully with a warning instead of crashing.
  • Adjusts _stop_gateway to signal both the wrapper and the Hermes child, preventing automatic restarts after an intentional stop.
  • Extends _start_gateway and gateway_reload_if_requested to track wrapper lifecycle: records precise failure diagnostics, clears failure state on success, and preserves pending reload requests while the wrapper is in a restart delay with no child.
  • Behavioral Change: Hermes process argv now includes --restart-delay and --external-supervisor; ps-based checks must match the new wrapper patterns.

Macroscope summarized b018989.

Greptile Summary

Hermes now preserves reload requests while the supervised gateway is between child processes, applies the deferred reload after recovery, and does not restart after an intentional disable. The reviewed behavior completed successfully.

Confidence Score: 5/5

No blocking failure remains.

The exercised process-recovery, deferred-reload, intentional-disable, and static contract flows completed successfully with no actionable issue found.

T-Rex T-Rex Logs

What T-Rex did

  • Ran the Hermes supervision reproduction harness through the guest/hb and guest/tx9-logs flow to exercise child crash recovery, a reload during restart delay, and intentional disable.
  • Observed that the parent consumed the reload request, while on the current revision the request was preserved, executed after the replacement child started, and intentional disable prevented another restart.
  • Ran the repository static contract check, which completed successfully, confirming the exercised gateway-supervision behavior works as intended.
  • No security impact observed: stale inherited-FD recovery was inspected, and the changed reload handling did not expose credentials or broaden process targeting.

View all artifacts

T-Rex Ran code and verified through T-Rex

Reviews (2): Last reviewed commit: "fix: preserve reload requests during gat..." | Re-trigger Greptile

The boot workload could stay healthy while the gateway stayed down:
hb launched nested tx9-logs with leftover TX9_LOG_MIRROR_*_FD values
whose descriptors were already closed, so capture crashed with EBADF
before exec, and the wrapper had no restart policy after a child exit.

Sanitize the mirror-FD pair after closing lock descriptors, run the
Hermes wrapper with --restart-delay, declare --external-supervisor,
and make tx9-logs fall back to owned stdout/stderr dups when inherited
FDs are missing, closed, or malformed.

Co-authored-by: Ben Davis <bmdavis419@users.noreply.github.com>
@coderabbitai

coderabbitai Bot commented Aug 13, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 45a2b4af-4f16-434f-9082-35f2c5342350

📥 Commits

Reviewing files that changed from the base of the PR and between 76bfea9 and b018989.

📒 Files selected for processing (2)
  • guest/hb
  • tests/regressions-hb-workload.sh
🚧 Files skipped from review as they are similar to previous changes (1)
  • guest/hb

📝 Walkthrough

Walkthrough

The change validates inherited mirror descriptors, adds configurable gateway restart supervision, reports startup failures, and makes gateway lifecycle handling aware of capture wrappers. Regression tests cover descriptor fallback, nested capture, reload retention, child replacement, shutdown, and diagnostics.

Changes

Gateway supervision and log capture

Layer / File(s) Summary
Inherited descriptor validation
guest/tx9-logs, tests/regressions-logs.sh
inherited_open_fd rejects invalid inherited descriptors. Capture falls back to duplicated standard streams and warns when metadata is invalid or incomplete.
Gateway startup and capture wiring
guest/hb, tests/regressions-hb-workload.sh, tests/regressions-logs.sh
Gateway startup sanitizes mirror descriptors, configures restart delay and external supervision, preserves launcher diagnostics, and reports wrapper failures.
Wrapper lifecycle and restart handling
guest/hb, tests/regressions-hb-workload.sh, tests/regressions-logs.sh
Process detection and shutdown include the capture wrapper. Reload requests remain pending during child replacement. Tests cover nested capture adoption, restart behavior, disable and pause handling, and wrapper termination.
Static integration checks
tests/static.sh
Static checks validate descriptor handling, restart configuration, supervision wiring, process detection, and nested capture failure handling.

Possibly related PRs

  • davis7dotsh/tx9#2: Shares Hermes gateway lifecycle, supervision, and failure-state handling.
  • davis7dotsh/tx9#7: Shares gateway stop and process-handling changes.
  • davis7dotsh/tx9#25: Shares gateway supervision, capture-wrapper lifecycle, restart behavior, and inherited descriptor handling.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly identifies the main fix for Hermes recovery from stale nested tx9-logs mirror file descriptors.
Description check ✅ Passed The description directly explains the mirror-FD recovery, restart supervision, lifecycle handling, and regression coverage.

Comment @coderabbitai help to get the list of available commands.

Comment thread guest/tx9-logs Outdated
Comment thread guest/tx9-logs
if fd < 0:
return None
try:
os.fstat(fd)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High guest/tx9-logs:864

inherited_open_fd accepts a stale FD number as valid whenever os.fstat(fd) succeeds, so FD reuse causes Capture.__init__ to dup2 unrelated files onto stdout/stderr. A reused read-only lock FD then makes mirrored writes fail, while a writable FD sends log output to the wrong file; validate that the descriptor is the expected mirror rather than only checking that it is open.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @guest/tx9-logs around line 864:

`inherited_open_fd` accepts a stale FD number as valid whenever `os.fstat(fd)` succeeds, so FD reuse causes `Capture.__init__` to `dup2` unrelated files onto stdout/stderr. A reused read-only lock FD then makes mirrored writes fail, while a writable FD sends log output to the wrong file; validate that the descriptor is the expected mirror rather than only checking that it is open.

os.fstat() raises OverflowError for values that do not fit a C int.
Catch that alongside OSError so Capture falls back instead of crashing,
and keep the restart-delay child fixture in a script so shellcheck stays quiet.

Co-authored-by: Ben Davis <bmdavis419@users.noreply.github.com>
@bmdavis419
bmdavis419 marked this pull request as ready for review August 13, 2026 01:02

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 3 potential issues.

View 2 additional findings in Devin Review.

Open in Devin Review

Comment thread guest/hb
Comment on lines +213 to +223
launcher_log="${logfile%.log}.launcher.log"
capture_cmd=(tx9-logs capture --source "$source" --log-dir "$log_dir")
if [[ "$source" == hermes ]]; then
# The wrapper is Hermes' process manager. Restart unexpected child
# exits; --external-supervisor makes in-chat restart/update return
# here instead of daemonizing a replacement.
capture_cmd+=(--restart-delay "$GATEWAY_RESTART_DELAY")
child_cmd+=(--external-supervisor)
fi
exec nohup "${capture_cmd[@]}" -- "${child_cmd[@]}" \
</dev/null >/dev/null 2>>"$launcher_log"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Every line Hermes prints is copied into a second log file that is never rotated or size-capped

The Hermes supervisor's error output is pointed at a plain, never-trimmed file (2>>"$launcher_log" at guest/hb:223) that the logging wrapper then also uses as its live mirror, so every line Hermes prints is appended there forever on the durable volume.

Impact: The box's persistent disk can slowly fill with an unbounded duplicate copy of the Hermes log, which the existing size limits and rotation never touch.

Why the launcher log receives the full Hermes stream, not just launcher errors

_spawn_without_lock_fd now runs tx9-logs capture with stdout to /dev/null and stderr appended to $LOGS/hermes-gateway.launcher.log (guest/hb:213, guest/hb:222-223). Inside Capture.__init__, when no valid TX9_LOG_MIRROR_*_FD pair is inherited — which is the normal case for a login-shell hb up, and also the exact stale-FD case this PR fixes — the wrapper dups its own stderr as the mirror descriptor (guest/tx9-logs:905-907). Capture.output unconditionally mirrors every captured chunk: write_all(self.mirror_out if stream == "stdout" else self.mirror_err, mirrored) (guest/tx9-logs:1029). So all of Hermes' stderr is written verbatim into hermes-gateway.launcher.log in addition to the rotated hermes-gateway.log/hermes.jsonl written by RotatingWriter.

The launcher log is a bare file: nothing in the repo rotates, truncates, or prunes it (rotated_paths at guest/tx9-logs:1440-1452 only matches hermes-gateway.log.<digits>, and logs() at guest/hb:925-937 only tails $LOGS/<name>.log). Before this change the same stream went to /dev/null (2>&1 into /dev/null), so nothing accumulated.

Prompt for agents
In guest/hb `_spawn_without_lock_fd`, the nested `tx9-logs capture` for Hermes is spawned with its stderr appended to `$LOGS/hermes-gateway.launcher.log`. That file is intended only to capture wrapper failures that happen before Hermes is exec'd, but `Capture` in guest/tx9-logs dups its own stderr as the docker mirror descriptor whenever no valid inherited TX9_LOG_MIRROR_*_FD pair exists (guest/tx9-logs:902-907), and `Capture.output` mirrors every captured line to that descriptor (guest/tx9-logs:1029). The result is that the entire Hermes output stream is duplicated into an unrotated, unpruned file on the durable /data volume, defeating TX9_LOG_MAX_BYTES/TX9_LOG_MAX_FILES.

Possible approaches: bound the launcher log (rotate/truncate it on each spawn, or cap it), or avoid making it the mirror target — e.g. keep the launcher log only for pre-exec diagnostics by having the wrapper stop using its inherited stderr as the mirror when it is a file rather than a terminal/pipe, or explicitly pass a distinct mirror target. Whatever is chosen, the durable log volume must not grow without bound.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread guest/hb
Comment on lines +179 to +181
_report_gateway_start_failure() {
_steady_report gateway-start failed "$1" stderr
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Repeated gateway start failures still print once per reconcile cycle into the durable log stream

The gateway start-failure message is routed through the repeat-suppressing reporter (_steady_report at guest/hb:180), but the periodic reconciliation never turns suppression on, so an ongoing failure is re-logged on every cycle exactly as before.

Impact: A persistently failing Hermes start floods the durable agent log with one identical line every reconcile cycle, and the new suppression state file is never used in production.

Why suppression never engages

_steady_report only consults/writes $HB_RUNTIME_STATE_DIR/<key>.state when HB_STEADY_QUIET == 1; otherwise it prints immediately and returns (guest/hb:54-57). reconcile() enables quiet mode only for the executor check — HB_STEADY_QUIET=1 _executor_up (guest/hb:426) — and then calls _start_gateway (guest/hb:427) with the default HB_STEADY_QUIET=0. hb-workload's loop invokes hb reconcile as a fresh process (guest/hb-workload:126) and does not export HB_STEADY_QUIET. The regression test only exercises the suppression by setting HB_STEADY_QUIET=1 by hand (tests/regressions-hb-workload.sh:1307), so the production path is uncovered.

Note also that both distinct failure messages share the single state value failed, so once quiet mode is enabled the second, different message would be suppressed as "unchanged".

Prompt for agents
guest/hb adds `_report_gateway_start_failure` which delegates to `_steady_report gateway-start failed ...`, but `_steady_report` only de-duplicates when HB_STEADY_QUIET=1, and `reconcile()` only sets that for `_executor_up`, not for `_start_gateway`. As a result the new suppression state file is never written in the reconcile loop and repeated gateway start failures are logged every cycle. Consider running the gateway start under quiet mode in `reconcile()` the same way the executor check is, and/or distinguish the two failure messages with different state values so quiet mode does not swallow a genuinely different failure.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread guest/hb

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (5)
guest/tx9-logs (2)

902-907: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Document the nested-capture tradeoff in the fallback branch.

The inherited branch explains why it must replace stdout and stderr (lines 912-915). The fallback branch intentionally breaks that same invariant: it duplicates the inherited stdout and stderr, so under a nested capture the parent pipes stay open for the nested lifetime and each line is recorded by both captures. That is the correct choice over crashing before exec, but a reader cannot see it here.

Add a short comment stating that duplicate mirroring is accepted to keep the target exec'able.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@guest/tx9-logs` around lines 902 - 907, In the fallback branch that
duplicates stdout and stderr when inherited descriptors are unavailable, add a
short comment documenting that nested captures may duplicate mirroring because
the parent pipes remain open, and that this tradeoff is accepted to keep the
target exec'able. Keep the existing fallback behavior unchanged.

847-867: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Align the docstring with what the check can prove.

os.fstat proves only that the FD number is currently open. It cannot prove the number still refers to the inherited mirror. If the descriptor was closed and the number was reused (for example by a lock or a log file), inherited_open_fd returns it and capture output is then written into the unrelated file. The hb path closes lock FDs before validation, so the residual risk comes from other parents.

State that limit in the docstring so later readers do not treat this as a reuse check.

📝 Proposed docstring wording
     Environment presence is not a validity contract. Nested capture can retain
     TX9_LOG_MIRROR_*_FD strings after those descriptors were closed, reused
     for a lock, or never inherited into this process.
+
+    The check only rejects values that are missing, malformed, negative, or
+    not open here. It cannot detect a number that was closed and then reused
+    by an unrelated descriptor; callers must close their own FDs first.
     """
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@guest/tx9-logs` around lines 847 - 867, Update the docstring for
inherited_open_fd to state that os.fstat verifies only that the numeric
descriptor is currently open, not that it still refers to the inherited mirror;
descriptor reuse by another file or lock remains possible. Keep the existing
validation logic unchanged.
guest/hb (3)

179-181: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Include the message in the dedup state token.

_steady_report compares only the state token (line 60). This reporter always passes failed, and two different failures use it: the pending-capture message (lines 352, 365) and the nested-exit message (lines 392-393). In quiet mode the second cause is suppressed after the first one is recorded, so the operator sees a stale reason.

♻️ Proposed change to keep distinct causes reportable
 _report_gateway_start_failure() {
-  _steady_report gateway-start failed "$1" stderr
+  _steady_report gateway-start "failed:$1" "$1" stderr
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@guest/hb` around lines 179 - 181, Update _report_gateway_start_failure so the
message argument is included in the state token passed to _steady_report, while
preserving the existing stderr reporting behavior. Ensure distinct gateway-start
failure messages produce distinct deduplication states and are not suppressed as
duplicates.

389-393: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Do not claim a pre-exec failure when the wrapper was still running.

_wait_gateway_pid returns 1 both when the wrapper already died and when it found more than one gateway process. In the second case this code sends TERM itself, so wrapper_status becomes 143 and the message reports "nested tx9-logs exited 143 before Hermes exec". Hermes did exec in that case, so the reported cause is wrong.

Record whether the wrapper was alive before the TERM, and report the two causes separately.

♻️ Proposed change
-    local wrapper_status=1
-    kill -TERM "$wrapper_pid" 2>/dev/null || true
-    wait "$wrapper_pid" 2>/dev/null && wrapper_status=0 || wrapper_status=$?
-    _report_gateway_start_failure \
-      "Hermes gateway failed to start; nested tx9-logs exited ${wrapper_status} before Hermes exec; see: $LOGS/hermes-gateway.launcher.log"
+    local wrapper_status=1 wrapper_was_live=0
+    kill -0 "$wrapper_pid" 2>/dev/null && wrapper_was_live=1
+    kill -TERM "$wrapper_pid" 2>/dev/null || true
+    wait "$wrapper_pid" 2>/dev/null && wrapper_status=0 || wrapper_status=$?
+    if [[ "$wrapper_was_live" == 1 ]]; then
+      _report_gateway_start_failure \
+        "Hermes gateway failed to start; the nested tx9-logs wrapper was stopped after an unclean launch; see: $LOGS/hermes-gateway.launcher.log"
+    else
+      _report_gateway_start_failure \
+        "Hermes gateway failed to start; nested tx9-logs exited ${wrapper_status} before Hermes exec; see: $LOGS/hermes-gateway.launcher.log"
+    fi

Note: tests/static.sh line 83 and tests/regressions-hb-workload.sh line 1306 assert the "nested tx9-logs exited" text, which this change preserves for the pre-exec case.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@guest/hb` around lines 389 - 393, Update the failure handling around
_wait_gateway_pid to record whether wrapper_pid was alive before sending TERM.
Report “nested tx9-logs exited … before Hermes exec” only when it had already
exited; when it was still running, use a separate message indicating multiple
gateway processes or the corresponding post-exec condition, while preserving the
existing pre-exec text.

214-223: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Pin Hermes or validate the CLI contract. The provisioner installs the latest upstream Hermes; no release pin exists. A future CLI change could make the gateway exit and restart repeatedly. Pin a compatible release or validate --external-supervisor during provisioning.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@guest/hb` around lines 214 - 223, Update the Hermes provisioning flow
associated with the gateway launch using --external-supervisor to either pin
Hermes to a known compatible release or validate that the installed CLI supports
this option before launching. Ensure provisioning fails clearly or selects a
compatible version when the contract is unavailable, preventing an unsupported
flag from causing restart loops.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@guest/hb`:
- Around line 446-452: Add a bounded polling wait after the pkill command in the
gateway stop flow, using the existing _gateway_or_capture_running check and
sleep pattern, before the final failure check. Allow the loop to exit once no
gateway or capture process remains, then preserve the existing error handling if
the process is still reported running.

---

Nitpick comments:
In `@guest/hb`:
- Around line 179-181: Update _report_gateway_start_failure so the message
argument is included in the state token passed to _steady_report, while
preserving the existing stderr reporting behavior. Ensure distinct gateway-start
failure messages produce distinct deduplication states and are not suppressed as
duplicates.
- Around line 389-393: Update the failure handling around _wait_gateway_pid to
record whether wrapper_pid was alive before sending TERM. Report “nested
tx9-logs exited … before Hermes exec” only when it had already exited; when it
was still running, use a separate message indicating multiple gateway processes
or the corresponding post-exec condition, while preserving the existing pre-exec
text.
- Around line 214-223: Update the Hermes provisioning flow associated with the
gateway launch using --external-supervisor to either pin Hermes to a known
compatible release or validate that the installed CLI supports this option
before launching. Ensure provisioning fails clearly or selects a compatible
version when the contract is unavailable, preventing an unsupported flag from
causing restart loops.

In `@guest/tx9-logs`:
- Around line 902-907: In the fallback branch that duplicates stdout and stderr
when inherited descriptors are unavailable, add a short comment documenting that
nested captures may duplicate mirroring because the parent pipes remain open,
and that this tradeoff is accepted to keep the target exec'able. Keep the
existing fallback behavior unchanged.
- Around line 847-867: Update the docstring for inherited_open_fd to state that
os.fstat verifies only that the numeric descriptor is currently open, not that
it still refers to the inherited mirror; descriptor reuse by another file or
lock remains possible. Keep the existing validation logic unchanged.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: e0bd283a-3777-4fb7-9340-7f086a4cf305

📥 Commits

Reviewing files that changed from the base of the PR and between a6e4f07 and 76bfea9.

📒 Files selected for processing (5)
  • guest/hb
  • guest/tx9-logs
  • tests/regressions-hb-workload.sh
  • tests/regressions-logs.sh
  • tests/static.sh

Comment thread guest/hb
Comment on lines 446 to +452
for ((attempt = 0; attempt < 30; attempt++)); do
_gateway_running || break
_gateway_or_capture_running || break
sleep 1
done
pkill -KILL -u "$(id -u)" -f '[h]ermes( .*)? gateway run' 2>/dev/null || true
rm -f "$GATEWAY_PID"
! _gateway_running || { echo "Hermes gateway failed to stop" >&2; return 1; }
! _gateway_or_capture_running || { echo "Hermes gateway failed to stop" >&2; return 1; }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Add a bounded wait after pkill -KILL before the final check.

pkill -KILL returns before the kernel reaps the targets. During that window a zombie gateway child still appears in pgrep, and _gateway_pids keeps it: ps -o args= exits 0 with empty output for a zombie, so the *"tx9-logs capture"* filter at line 264 does not skip it. _gateway_or_capture_running is then true and _stop_gateway returns 1 with "Hermes gateway failed to stop" after a successful stop. _wait_gateway_pid line 304 already treats Z* as not running, so the file already accounts for this state elsewhere.

🛡️ Proposed fix
   pkill -KILL -u "$(id -u)" -f '[h]ermes( .*)? gateway run' 2>/dev/null || true
+  for ((attempt = 0; attempt < 50; attempt++)); do
+    _gateway_or_capture_running || break
+    sleep 0.1
+  done
   rm -f "$GATEWAY_PID"
   ! _gateway_or_capture_running || { echo "Hermes gateway failed to stop" >&2; return 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.

Suggested change
for ((attempt = 0; attempt < 30; attempt++)); do
_gateway_running || break
_gateway_or_capture_running || break
sleep 1
done
pkill -KILL -u "$(id -u)" -f '[h]ermes( .*)? gateway run' 2>/dev/null || true
rm -f "$GATEWAY_PID"
! _gateway_running || { echo "Hermes gateway failed to stop" >&2; return 1; }
! _gateway_or_capture_running || { echo "Hermes gateway failed to stop" >&2; return 1; }
for ((attempt = 0; attempt < 30; attempt++)); do
_gateway_or_capture_running || break
sleep 1
done
pkill -KILL -u "$(id -u)" -f '[h]ermes( .*)? gateway run' 2>/dev/null || true
for ((attempt = 0; attempt < 50; attempt++)); do
_gateway_or_capture_running || break
sleep 0.1
done
rm -f "$GATEWAY_PID"
! _gateway_or_capture_running || { echo "Hermes gateway failed to stop" >&2; return 1; }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@guest/hb` around lines 446 - 452, Add a bounded polling wait after the pkill
command in the gateway stop flow, using the existing _gateway_or_capture_running
check and sleep pattern, before the final failure check. Allow the loop to exit
once no gateway or capture process remains, then preserve the existing error
handling if the process is still reported running.

Co-authored-by: Ben Davis <bmdavis419@users.noreply.github.com>
@bmdavis419
bmdavis419 merged commit c9de8d0 into main Aug 14, 2026
4 checks passed

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

View 3 additional findings in Devin Review.

Open in Devin Review

Comment thread guest/hb
Comment on lines +222 to +223
exec nohup "${capture_cmd[@]}" -- "${child_cmd[@]}" \
</dev/null >/dev/null 2>>"$launcher_log"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟨 Gateway launcher log is created with default permissions and may hold redacted-only child output

The new launcher log at guest/hb:222-223 is created by a plain shell append redirect (2>>"$launcher_log"), so it inherits the ambient umask rather than the 0o600 mode that tx9-logs deliberately enforces on every other log file it writes (guest/tx9-logs:771-783, and os.fchmod(fd, 0o600)). Because the mirror fallback in Capture.__init__ (guest/tx9-logs:902-907) dups this descriptor into mirror_err, the gateway's stderr stream ends up in this less-protected file whenever no valid inherited mirror pair exists.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants