Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 16 additions & 24 deletions plugin/scripts/dashboard-service.sh
Original file line number Diff line number Diff line change
Expand Up @@ -126,9 +126,16 @@ spawn_dashboard() {
fi
cd "$DASHBOARD_DIR"
export CLAUDE_SMART_DASHBOARD_WORKSPACE="$WORKSPACE_CWD"
# Invoke Next directly so custom DASHBOARD_PORT/PORT values are honored
# without relying on package.json's fixed start script.
CLAUDE_SMART_SPAWN_KEEP_OUTPUT=1 claude_smart_spawn_detached "$NEXT_BIN" start -p "$PORT" -H 127.0.0.1 >>"$LOG_FILE" 2>&1
# Run next-server under dashboard-supervise.sh so a later silent death (crash,
# or a macOS jetsam pressure-kill of next-server) self-heals within seconds
# instead of waiting for the next SessionStart hook to notice a dead marker.
# The supervisor is the detached session leader and is what we record in
# dashboard.pid, so `stop`'s process-group kill takes it and its next-server
# child down together — an intentional stop never triggers a respawn. Next is
# still invoked directly (inside the supervisor) so custom DASHBOARD_PORT/PORT
# values are honored without relying on package.json's fixed start script.
CLAUDE_SMART_SPAWN_KEEP_OUTPUT=1 claude_smart_spawn_detached \
bash "$HERE/dashboard-supervise.sh" "$NEXT_BIN" "$PORT" >>"$LOG_FILE" 2>&1
dash_pid=$!
echo "$dash_pid" > "$PID_FILE"
}
Expand Down Expand Up @@ -214,28 +221,13 @@ case "$CMD" in
fi
if wait_for_dashboard_marker 5; then
claude_smart_clear_dashboard_unavailable
emit_ok; exit 0
fi
if ! kill -0 "$dash_pid" 2>/dev/null; then
sleep 2
if port_occupied; then
if marker_responds; then
claude_smart_clear_dashboard_unavailable
else
claude_smart_write_dashboard_unavailable "dashboard process exited and port $PORT is now held by a non-claude-smart listener; see $LOG_FILE"
fi
else
echo "[claude-smart] dashboard: first start exited before readiness; retrying once" >>"$LOG_FILE"
if spawn_dashboard; then
if wait_for_dashboard_marker 5; then
claude_smart_clear_dashboard_unavailable
else
claude_smart_write_dashboard_unavailable "dashboard process spawned but did not respond on http://127.0.0.1:$PORT within 5s; see $LOG_FILE"
fi
fi
fi
else
claude_smart_write_dashboard_unavailable "dashboard process spawned but did not respond on http://127.0.0.1:$PORT within 5s; see $LOG_FILE"
# next-server did not answer within 5s. The supervisor keeps retrying it
# in the background (bounded by its crash-loop guard), so surface a soft
# "unavailable" marker for now; a later SessionStart clears it once the
# marker responds. We do NOT re-spawn here — that would start a second
# supervisor racing for the port.
claude_smart_write_dashboard_unavailable "dashboard is still starting or did not respond on http://127.0.0.1:$PORT within 5s; see $LOG_FILE"
fi
emit_ok
;;
Expand Down
88 changes: 88 additions & 0 deletions plugin/scripts/dashboard-supervise.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
#!/usr/bin/env bash
# Supervisor for the claude-smart Next.js dashboard.
#
# Problem it solves: dashboard-service.sh used to fire `next start` detached and
# forget about it. When that process died later (a crash, or a macOS jetsam
# pressure-kill of next-server), nothing restarted it until the next *real*
# SessionStart hook happened to run `start` again — leaving the dashboard down
# for as long as Claude Code stayed closed. This wrapper respawns next-server so
# a silent death self-heals within seconds.
#
# Lifecycle: dashboard-service.sh spawn_dashboard() launches this detached as the
# session leader (setsid) and records THIS pid in dashboard.pid. `stop`
# group-kills that pid, so the TERM trap below breaks the loop and the
# next-server child (same process group) is torn down with us — an intentional
# stop never triggers a respawn.
#
# Crash-loop guard: a broken build that starts and immediately exits must not be
# respawned forever. Exits faster than HEALTHY_SECS are counted as consecutive
# fast failures; after MAX_FAILS of them the supervisor gives up (exit 1) and
# leaves recovery to the next SessionStart. Any run that stays up at least
# HEALTHY_SECS resets the counter, so a long-lived dashboard that eventually
# dies still self-heals.
#
# Note (deliberate limitation): this is an in-process supervisor. A system-wide
# memory-pressure kill can take the whole process group (supervisor + child)
# down at once, in which case recovery still falls to the next SessionStart. A
# launchd/OS-level supervisor would survive that; it is intentionally left as a
# follow-up.
#
# Tunables (env; defaults chosen for production, overridden in tests):
# CLAUDE_SMART_DASHBOARD_RESPAWN_DELAY seconds to wait between respawns (2)
# CLAUDE_SMART_DASHBOARD_HEALTHY_SECS uptime that counts as healthy (30)
# CLAUDE_SMART_DASHBOARD_MAX_FAILS consecutive fast exits allowed (5)
set -u

NEXT_BIN="${1:?dashboard-supervise.sh: next binary path required}"
PORT="${2:?dashboard-supervise.sh: port required}"

RESPAWN_DELAY="${CLAUDE_SMART_DASHBOARD_RESPAWN_DELAY:-2}"
HEALTHY_SECS="${CLAUDE_SMART_DASHBOARD_HEALTHY_SECS:-30}"
MAX_FAILS="${CLAUDE_SMART_DASHBOARD_MAX_FAILS:-5}"

# On stop (TERM/INT — normally delivered to the whole process group by
# `dashboard-service.sh stop`) tear down the current next-server and exit the
# loop instead of respawning. next-server runs in the background and we `wait`
# on it so the trap fires immediately, even if only this supervisor is signaled.
child=""
on_stop() {
[ -n "$child" ] && kill -TERM "$child" 2>/dev/null
echo "[claude-smart] dashboard supervisor: received stop signal; exiting"
exit 0
}
trap on_stop TERM INT

fails=0
while true; do
started="$(date +%s 2>/dev/null || echo 0)"
"$NEXT_BIN" start -p "$PORT" -H 127.0.0.1 &
child=$!
wait "$child"
code=$?
ended="$(date +%s 2>/dev/null || echo 0)"
Comment on lines +57 to +62

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Use Bash's built-in $SECONDS to prevent a potential crash-loop bug and avoid subshells.

If the environment's date command does not support +%s (e.g., in some lightweight environments), the fallback echo 0 causes both started and ended to evaluate to 0. Their difference will always be 0, forcing the supervisor to increment fails on every exit and permanently give up after hitting MAX_FAILS, regardless of how long the dashboard actually stayed up.

Using Bash's built-in $SECONDS tracks elapsed time natively, avoiding external process forks and completely eliminating this failure mode.

♻️ Proposed refactor
-  started="$(date +%s 2>/dev/null || echo 0)"
+  started=$SECONDS
   "$NEXT_BIN" start -p "$PORT" -H 127.0.0.1 &
   child=$!
   wait "$child"
   code=$?
-  ended="$(date +%s 2>/dev/null || echo 0)"
+  ended=$SECONDS
📝 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
started="$(date +%s 2>/dev/null || echo 0)"
"$NEXT_BIN" start -p "$PORT" -H 127.0.0.1 &
child=$!
wait "$child"
code=$?
ended="$(date +%s 2>/dev/null || echo 0)"
started=$SECONDS
"$NEXT_BIN" start -p "$PORT" -H 127.0.0.1 &
child=$!
wait "$child"
code=$?
ended=$SECONDS
🤖 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 `@plugin/scripts/dashboard-supervise.sh` around lines 57 - 62, Replace the
external date-based assignments for started and ended in the dashboard
supervision flow with Bash’s built-in SECONDS value, preserving the elapsed-time
calculation used after wait and preventing zero-duration results when date is
unavailable.


# 128+15=143: next-server was SIGTERM'd on its own — normally the port reaper
# in `dashboard-service.sh stop`/reinstall when it could not see this
# supervisor's pid (e.g. a stop issued from a different HOME). Respect an
# intentional stop instead of resurrecting the dashboard. Genuine crashes and
# jetsam SIGKILLs (137) fall through to the respawn path below. (A stop that
# *does* see our pid group-kills this supervisor, handled by the trap above.)
if [ "$code" -eq 143 ]; then
echo "[claude-smart] dashboard supervisor: next-server received SIGTERM (intentional stop); exiting without respawn"
exit 0
fi

if [ "$((ended - started))" -ge "$HEALTHY_SECS" ]; then
fails=0
else
fails=$((fails + 1))
fi

if [ "$fails" -ge "$MAX_FAILS" ]; then
echo "[claude-smart] dashboard supervisor: next-server exited (code $code); ${MAX_FAILS} fast failures in a row — giving up (recovery deferred to next SessionStart)"
exit 1
fi

echo "[claude-smart] dashboard supervisor: next-server exited (code $code); respawning in ${RESPAWN_DELAY}s (consecutive fast failures: $fails)"
sleep "$RESPAWN_DELAY"
done
17 changes: 17 additions & 0 deletions plugin/scripts/ensure-plugin-root.sh
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,19 @@ write_plugin_root_metadata() {
printf '%s\n' "$TARGET" >"$HOME/.reflexio/plugin-root.txt"
}

# Keep plugin-root.txt in sync with the symlink for the file-based readers
# (OpenCode server.mts fallback, Codex/Windows-junction consumers) even on the
# paths where we leave an already-valid link untouched. Without this the file
# drifts: the symlink is refreshed every SessionStart but plugin-root.txt was
# only ever written at install time, so after a version change it can point at a
# stale (even deleted) root while the symlink is correct.
reconcile_metadata_from_link() {
[ -L "$LINK" ] || return 0
linked="$(cd "$LINK" 2>/dev/null && pwd -P || true)"
[ -n "$linked" ] || return 0
printf '%s\n' "$linked" >"$HOME/.reflexio/plugin-root.txt"
}

write_plugin_root_link() {
reason="$1"
if claude_smart_is_windows; then
Expand Down Expand Up @@ -115,6 +128,7 @@ write_plugin_root_link() {
fi

ln -sfn "$TARGET" "$LINK"
write_plugin_root_metadata
echo "[claude-smart] plugin-root → $TARGET${reason:+ ($reason)}" >&2
}

Expand Down Expand Up @@ -162,6 +176,8 @@ if [ -n "$CURRENT" ]; then
TARGET_NORM="${TARGET%/}"
if [ "$CURRENT_NORM" != "$TARGET_NORM" ]; then
write_plugin_root_link "cache-tracking, was $CURRENT"
else
reconcile_metadata_from_link
fi
exit 0
;;
Expand All @@ -171,6 +187,7 @@ fi
# Self-heal path: only rewrite the link if it's missing or its target is
# gone/invalid.
if [ -f "$LINK/pyproject.toml" ]; then
reconcile_metadata_from_link
exit 0
fi

Expand Down
123 changes: 121 additions & 2 deletions tests/test_install_scripts.py
Original file line number Diff line number Diff line change
Expand Up @@ -534,9 +534,11 @@ def test_detached_spawns_with_log_redirection_preserve_output_on_windows() -> No
"CLAUDE_SMART_SPAWN_KEEP_OUTPUT=1 claude_smart_spawn_detached env "
"CLAUDE_SMART_BOOTSTRAPPING=1"
) in dashboard
# The dashboard is now spawned under the respawn supervisor, but still with
# output preserved and redirected to the shared dashboard log.
assert "CLAUDE_SMART_SPAWN_KEEP_OUTPUT=1 claude_smart_spawn_detached" in dashboard
assert (
"CLAUDE_SMART_SPAWN_KEEP_OUTPUT=1 claude_smart_spawn_detached "
'"$NEXT_BIN" start -p "$PORT" -H 127.0.0.1 >>"$LOG_FILE" 2>&1'
'bash "$HERE/dashboard-supervise.sh" "$NEXT_BIN" "$PORT" >>"$LOG_FILE" 2>&1'
in dashboard
)

Expand Down Expand Up @@ -5769,6 +5771,123 @@ def test_dashboard_build_writes_marker_when_npm_missing(tmp_path: Path) -> None:
assert "npm is not on PATH" in marker.read_text()


def test_dashboard_service_supervises_next_for_self_heal() -> None:
"""dashboard-service.sh runs next-server under the respawn supervisor."""
service = (REPO_ROOT / "plugin" / "scripts" / "dashboard-service.sh").read_text()
assert "dashboard-supervise.sh" in service

supervise = (
REPO_ROOT / "plugin" / "scripts" / "dashboard-supervise.sh"
).read_text()
# Clean stop: a TERM/INT trap must break the loop so `stop`'s group-kill
# never triggers a respawn.
assert "trap" in supervise
assert "TERM" in supervise and "INT" in supervise
# Crash-loop guard must be present and tunable.
assert "CLAUDE_SMART_DASHBOARD_MAX_FAILS" in supervise
assert "CLAUDE_SMART_DASHBOARD_HEALTHY_SECS" in supervise
# Must not resurrect a dashboard that was intentionally stopped (child
# SIGTERM == 143) — only crashes/SIGKILL are respawned.
assert "143" in supervise


def test_dashboard_supervisor_exits_on_intentional_stop_signal(
tmp_path: Path,
) -> None:
"""When next-server exits via SIGTERM (143) — i.e. a `stop`/reinstall port
reap — the supervisor exits without respawning, rather than fighting it."""
next_bin = tmp_path / "next"
_write_executable(next_bin, '#!/bin/sh\necho x >> "$HOME/calls"\nexit 143\n')
env = _isolated_env(tmp_path)
env["PATH"] = _minimal_path(tmp_path, "date", "sleep")
result = subprocess.run(
[
"/bin/bash",
"--noprofile",
"--norc",
str(REPO_ROOT / "plugin" / "scripts" / "dashboard-supervise.sh"),
str(next_bin),
"3999",
],
env=env,
text=True,
capture_output=True,
check=False,
timeout=15,
)
assert result.returncode == 0, result.stderr
assert (tmp_path / "calls").read_text().count("x") == 1
assert "intentional stop" in result.stdout


def test_dashboard_supervisor_respawns_then_gives_up_on_crash_loop(
tmp_path: Path,
) -> None:
"""A next-server that keeps dying fast is respawned up to MAX_FAILS, then
the supervisor gives up (exit 1) instead of tight-looping forever."""
next_bin = tmp_path / "next"
_write_executable(next_bin, '#!/bin/sh\necho x >> "$HOME/calls"\nexit 1\n')
env = _isolated_env(tmp_path)
env["PATH"] = _minimal_path(tmp_path, "date", "sleep")
env["CLAUDE_SMART_DASHBOARD_RESPAWN_DELAY"] = "0"
env["CLAUDE_SMART_DASHBOARD_HEALTHY_SECS"] = "5"
env["CLAUDE_SMART_DASHBOARD_MAX_FAILS"] = "3"
result = subprocess.run(
[
"/bin/bash",
"--noprofile",
"--norc",
str(REPO_ROOT / "plugin" / "scripts" / "dashboard-supervise.sh"),
str(next_bin),
"3999",
],
env=env,
text=True,
capture_output=True,
check=False,
timeout=30,
)
assert result.returncode == 1, result.stderr
calls = (tmp_path / "calls").read_text().count("x")
assert calls == 3, f"expected 3 spawns before giving up, got {calls}"
assert "giving up" in result.stdout


def test_ensure_plugin_root_syncs_stale_txt_to_valid_link(tmp_path: Path) -> None:
"""A valid symlink with a stale plugin-root.txt (the observed multi-host
drift) must reconcile .txt to the link so the file-based readers agree."""
target = tmp_path / "plugin"
(target / "scripts").mkdir(parents=True)
(target / "pyproject.toml").write_text("[project]\nname = 'x'\n")
reflexio = tmp_path / ".reflexio"
reflexio.mkdir()
link = reflexio / "plugin-root"
link.symlink_to(target)
stale = reflexio / "plugin-root.txt"
stale.write_text("/nonexistent/claude-smart/0.2.50\n")

env = _isolated_env(tmp_path)
env["PATH"] = _minimal_path(
tmp_path, "dirname", "uname", "cat", "readlink", "mkdir", "ln"
)
result = subprocess.run(
[
"/bin/bash",
"--noprofile",
"--norc",
str(REPO_ROOT / "plugin" / "scripts" / "ensure-plugin-root.sh"),
str(target),
],
env=env,
text=True,
capture_output=True,
check=False,
)
assert result.returncode == 0, result.stderr
assert stale.read_text().strip() == str(target.resolve())
assert link.resolve() == target.resolve()


def test_codex_claude_compat_translates_claude_contract(tmp_path: Path) -> None:
output_file = tmp_path / "captured-output-path"
codex = tmp_path / "codex"
Expand Down
Loading