Add restart command, fix post-update restarts as well.

This commit is contained in:
2025-12-17 10:50:31 +00:00
parent 35d575799c
commit bd35e2d9d6
12 changed files with 206 additions and 42 deletions
+3
View File
@@ -266,6 +266,9 @@ archives:
- src: plugin/hooks/hooks.json
dst: hooks
strip_parent: true
- src: plugin/commands/*
dst: commands
strip_parent: true
format_overrides:
- goos: windows
formats:
+3
View File
@@ -138,10 +138,13 @@ install: build stop-worker
@# Install to marketplaces directory (for direct installs)
@mkdir -p $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/hooks
@mkdir -p $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/.claude-plugin
@mkdir -p $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/commands
cp $(BUILD_DIR)/worker $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/
cp $(BUILD_DIR)/mcp-server $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/
cp $(BUILD_DIR)/hooks/* $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/hooks/
cp $(PLUGIN_DIR)/hooks/hooks.json $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/hooks/
@# Copy slash commands if they exist
@if [ -d "$(PLUGIN_DIR)/commands" ]; then cp -r $(PLUGIN_DIR)/commands/* $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/commands/ 2>/dev/null || true; fi
@# Update plugin.json and marketplace.json with current version to prevent stale version directories
@sed 's/"version": "[^"]*"/"version": "$(VERSION)"/g' $(PLUGIN_DIR)/.claude-plugin/plugin.json > $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/.claude-plugin/plugin.json
@sed 's/"version": "[^"]*"/"version": "$(VERSION)"/g' $(PLUGIN_DIR)/.claude-plugin/marketplace.json > $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/.claude-plugin/marketplace.json
+80 -37
View File
@@ -469,57 +469,100 @@ func (u *Updater) extractTarGz(archivePath, destDir string) error {
}
func (u *Updater) replaceBinaries(extractDir string) error {
// Files to replace
binaries := []struct {
src string
dest string
}{
{"worker", filepath.Join(u.installDir, "worker")},
{"mcp-server", filepath.Join(u.installDir, "mcp-server")},
{"hooks/session-start", filepath.Join(u.installDir, "hooks", "session-start")},
{"hooks/user-prompt", filepath.Join(u.installDir, "hooks", "user-prompt")},
{"hooks/post-tool-use", filepath.Join(u.installDir, "hooks", "post-tool-use")},
{"hooks/stop", filepath.Join(u.installDir, "hooks", "stop")},
{"hooks/subagent-stop", filepath.Join(u.installDir, "hooks", "subagent-stop")},
// Binary files to replace
binaryFiles := []string{
"worker",
"mcp-server",
"hooks/session-start",
"hooks/user-prompt",
"hooks/post-tool-use",
"hooks/stop",
"hooks/subagent-stop",
"hooks/statusline",
}
for _, b := range binaries {
src := filepath.Join(extractDir, b.src)
if _, err := os.Stat(src); os.IsNotExist(err) {
continue // Skip if not in archive
}
// Get all install directories (marketplaces and cache directories)
installDirs := u.getInstallDirectories()
// Backup existing binary
if _, err := os.Stat(b.dest); err == nil {
backup := b.dest + ".bak"
if err := os.Rename(b.dest, backup); err != nil {
return fmt.Errorf("failed to backup %s: %w", b.dest, err)
for _, installDir := range installDirs {
log.Debug().Str("dir", installDir).Msg("Installing binaries to directory")
for _, binaryFile := range binaryFiles {
src := filepath.Join(extractDir, binaryFile)
if _, err := os.Stat(src); os.IsNotExist(err) {
continue // Skip if not in archive
}
defer func(backup, dest string) {
// Clean up backup on success, restore on failure
if _, err := os.Stat(dest); err == nil {
_ = os.Remove(backup)
dest := filepath.Join(installDir, binaryFile)
// Backup existing binary
if _, err := os.Stat(dest); err == nil {
backup := dest + ".bak"
if err := os.Rename(dest, backup); err != nil {
log.Warn().Err(err).Str("file", dest).Msg("Failed to backup, continuing anyway")
} else {
_ = os.Rename(backup, dest)
defer func(backup, dest string) {
// Clean up backup on success, restore on failure
if _, err := os.Stat(dest); err == nil {
_ = os.Remove(backup)
} else {
_ = os.Rename(backup, dest)
}
}(backup, dest)
}
}(backup, b.dest)
}
}
// Copy new binary
if err := copyFile(src, b.dest); err != nil {
return fmt.Errorf("failed to install %s: %w", b.dest, err)
}
// Copy new binary
if err := copyFile(src, dest); err != nil {
return fmt.Errorf("failed to install %s: %w", dest, err)
}
// Make executable
// #nosec G302 -- executables require 0755 permissions
if err := os.Chmod(b.dest, 0755); err != nil {
return fmt.Errorf("failed to chmod %s: %w", b.dest, err)
// Make executable
// #nosec G302 -- executables require 0755 permissions
if err := os.Chmod(dest, 0755); err != nil {
return fmt.Errorf("failed to chmod %s: %w", dest, err)
}
}
}
return nil
}
// getInstallDirectories returns all directories where binaries should be installed.
// This includes the marketplaces directory and any cache directories.
func (u *Updater) getInstallDirectories() []string {
dirs := []string{u.installDir}
// Also check cache directories where Claude Code looks for plugins
home, err := os.UserHomeDir()
if err != nil {
return dirs
}
// Look for cache directories under ~/.claude/plugins/cache/claude-mnemonic/claude-mnemonic/
cacheBase := filepath.Join(home, ".claude/plugins/cache/claude-mnemonic/claude-mnemonic")
entries, err := os.ReadDir(cacheBase)
if err != nil {
return dirs
}
for _, entry := range entries {
if entry.IsDir() {
cacheDir := filepath.Join(cacheBase, entry.Name())
// Only add if it's different from installDir and contains a worker binary
if cacheDir != u.installDir {
workerPath := filepath.Join(cacheDir, "worker")
if _, err := os.Stat(workerPath); err == nil {
dirs = append(dirs, cacheDir)
log.Debug().Str("dir", cacheDir).Msg("Found cache directory to update")
}
}
}
}
return dirs
}
func copyFile(src, dst string) error {
// Ensure parent directory exists
if err := os.MkdirAll(filepath.Dir(dst), 0750); err != nil {
+27 -1
View File
@@ -1024,7 +1024,7 @@ func (s *Service) handleSelfCheck(w http.ResponseWriter, r *http.Request) {
})
}
// handleUpdateRestart restarts the worker with the new binary.
// handleUpdateRestart restarts the worker with the new binary (after update).
func (s *Service) handleUpdateRestart(w http.ResponseWriter, r *http.Request) {
status := s.updater.GetStatus()
if status.State != "done" {
@@ -1050,3 +1050,29 @@ func (s *Service) handleUpdateRestart(w http.ResponseWriter, r *http.Request) {
}
}()
}
// handleRestart restarts the worker process (general restart, not tied to update).
func (s *Service) handleRestart(w http.ResponseWriter, r *http.Request) {
log.Info().Msg("Manual restart requested via API")
// Send response before restarting
writeJSON(w, map[string]interface{}{
"success": true,
"message": "Restarting worker...",
"version": s.version,
})
// Flush the response
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
// Restart in background after response is sent
go func() {
// Small delay to ensure response is sent
time.Sleep(100 * time.Millisecond)
if err := s.updater.Restart(); err != nil {
log.Error().Err(err).Msg("Failed to restart worker")
}
}()
}
+3
View File
@@ -635,6 +635,9 @@ func (s *Service) setupRoutes() {
s.router.Get("/api/update/status", s.handleUpdateStatus)
s.router.Post("/api/update/restart", s.handleUpdateRestart)
// General restart endpoint (works before DB is ready)
s.router.Post("/api/restart", s.handleRestart)
// Selfcheck endpoint (works before DB is ready - checks all components)
s.router.Get("/api/selfcheck", s.handleSelfCheck)
+21
View File
@@ -0,0 +1,21 @@
# Restart Claude Mnemonic Worker
Restart the claude-mnemonic worker process. Use this command when experiencing issues with the memory system.
## Instructions
1. Call the restart API endpoint using curl:
```bash
curl -X POST http://127.0.0.1:37777/api/restart
```
2. Wait a moment for the worker to restart (typically 1-2 seconds)
3. Verify the worker is running by checking the version:
```bash
curl -s http://127.0.0.1:37777/api/version
```
4. Report the result to the user, including the version number from the response.
If the restart fails, suggest the user check `/tmp/claude-mnemonic-worker.log` for errors.
+7
View File
@@ -176,6 +176,7 @@ download_release() {
info "Installing to ${INSTALL_DIR}..."
mkdir -p "$INSTALL_DIR/hooks"
mkdir -p "$INSTALL_DIR/.claude-plugin"
mkdir -p "$INSTALL_DIR/commands"
# Copy binaries
cp "$tmp_dir/worker" "$INSTALL_DIR/"
@@ -185,6 +186,11 @@ download_release() {
# Copy plugin configuration
cp "$tmp_dir/.claude-plugin/"* "$INSTALL_DIR/.claude-plugin/"
# Copy slash commands if they exist in the release
if [[ -d "$tmp_dir/commands" ]]; then
cp -r "$tmp_dir/commands/"* "$INSTALL_DIR/commands/" 2>/dev/null || true
fi
# Make binaries executable
chmod +x "$INSTALL_DIR/worker"
chmod +x "$INSTALL_DIR/mcp-server"
@@ -230,6 +236,7 @@ register_plugin() {
# Copy files to cache directory
mkdir -p "$cache_path/.claude-plugin"
mkdir -p "$cache_path/hooks"
mkdir -p "$cache_path/commands"
cp -r "$INSTALL_DIR/"* "$cache_path/" 2>/dev/null || true
# Register in installed_plugins.json
+1
View File
@@ -45,6 +45,7 @@ if command -v jq &> /dev/null; then
# Ensure cache directory exists and copy plugin files
mkdir -p "$CACHE_PATH/.claude-plugin"
mkdir -p "$CACHE_PATH/hooks"
mkdir -p "$CACHE_PATH/commands"
# Copy files from marketplace to cache
cp -r "$MARKETPLACE_PATH/"* "$CACHE_PATH/" 2>/dev/null || true
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "claude-mnemonic-dashboard",
"version": "v0.6.11-2-g8ff0873-dirty",
"version": "v0.6.15-2-gd8a4939-dirty",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "claude-mnemonic-dashboard",
"version": "v0.6.11-2-g8ff0873-dirty",
"version": "v0.6.15-2-gd8a4939-dirty",
"dependencies": {
"vue": "^3.5.13"
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "claude-mnemonic-dashboard",
"version": "v0.6.11-2-g8ff0873-dirty",
"version": "v0.6.15-2-gd8a4939-dirty",
"private": true,
"type": "module",
"scripts": {
+25 -1
View File
@@ -7,7 +7,7 @@ import Timeline from '@/components/Timeline.vue'
import Sidebar from '@/components/Sidebar.vue'
// Composables
const { isConnected, isProcessing, queueDepth } = useSSE()
const { isConnected, isReconnecting, reconnectCountdown, isProcessing, queueDepth } = useSSE()
const { stats } = useStats()
const { updateInfo, updateStatus, isUpdating, applyUpdate } = useUpdate()
const { health } = useHealth()
@@ -33,6 +33,17 @@ const {
<template>
<div class="min-h-screen">
<!-- Reconnection Banner -->
<Transition name="slide">
<div
v-if="isReconnecting"
class="fixed top-0 left-0 right-0 z-50 bg-amber-500/90 backdrop-blur-sm text-black px-4 py-2 text-center text-sm font-medium shadow-lg"
>
<i class="fas fa-sync-alt fa-spin mr-2" />
Connection lost. Reconnecting<span v-if="reconnectCountdown > 0"> in {{ reconnectCountdown }}s</span>...
</div>
</Transition>
<!-- Header -->
<Header
:is-connected="isConnected"
@@ -94,3 +105,16 @@ const {
</main>
</div>
</template>
<style scoped>
.slide-enter-active,
.slide-leave-active {
transition: transform 0.3s ease, opacity 0.3s ease;
}
.slide-enter-from,
.slide-leave-to {
transform: translateY(-100%);
opacity: 0;
}
</style>
+33
View File
@@ -3,12 +3,15 @@ import type { SSEEvent } from '@/types'
// Singleton state - shared across all useSSE() calls
const isConnected = ref(false)
const isReconnecting = ref(false)
const reconnectCountdown = ref(0)
const isProcessing = ref(false)
const queueDepth = ref(0)
const lastEvent = ref<SSEEvent | null>(null)
let eventSource: EventSource | null = null
let reconnectTimeout: number | null = null
let countdownInterval: number | null = null
let connectionCount = 0
let reconnectAttempt = 0
@@ -24,6 +27,28 @@ function getBackoffDelay(): number {
return Math.floor(baseDelay + jitter)
}
function startCountdown(delayMs: number) {
reconnectCountdown.value = Math.ceil(delayMs / 1000)
if (countdownInterval) {
clearInterval(countdownInterval)
}
countdownInterval = window.setInterval(() => {
reconnectCountdown.value = Math.max(0, reconnectCountdown.value - 1)
if (reconnectCountdown.value <= 0 && countdownInterval) {
clearInterval(countdownInterval)
countdownInterval = null
}
}, 1000)
}
function stopCountdown() {
if (countdownInterval) {
clearInterval(countdownInterval)
countdownInterval = null
}
reconnectCountdown.value = 0
}
export function useSSE() {
const connect = () => {
@@ -36,6 +61,8 @@ export function useSSE() {
eventSource.onopen = () => {
isConnected.value = true
isReconnecting.value = false
stopCountdown()
reconnectAttempt = 0 // Reset backoff on successful connection
console.log('[SSE] Connected')
}
@@ -62,6 +89,7 @@ export function useSSE() {
eventSource.onerror = () => {
isConnected.value = false
isReconnecting.value = true
eventSource?.close()
eventSource = null
@@ -70,6 +98,7 @@ export function useSSE() {
reconnectAttempt++
console.log(`[SSE] Reconnecting in ${Math.round(delay/1000)}s (attempt ${reconnectAttempt})`)
startCountdown(delay)
reconnectTimeout = window.setTimeout(() => {
connect()
}, delay)
@@ -81,11 +110,13 @@ export function useSSE() {
clearTimeout(reconnectTimeout)
reconnectTimeout = null
}
stopCountdown()
if (eventSource) {
eventSource.close()
eventSource = null
}
isConnected.value = false
isReconnecting.value = false
}
// Handle page unload/refresh to ensure SSE connection is closed immediately
@@ -132,6 +163,8 @@ export function useSSE() {
return {
isConnected,
isReconnecting,
reconnectCountdown,
isProcessing,
queueDepth,
lastEvent,