fix: restart backend under systemd and use .sproutclaw agent dir

Restart via systemctl when running as a service, set SPROUTCLAW_CODING_AGENT_DIR
for the RPC subprocess, and add a frontend poll timeout for failed restarts.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-24 10:29:28 +08:00
parent 767a776a3e
commit bd2251e89a
7 changed files with 123 additions and 32 deletions

View File

@@ -32,8 +32,8 @@ type Config struct {
func New() *Config {
port := flag.Int("port", 19133, "HTTP port")
agentDir := flag.String("agent-dir", "", "pi agent directory (e.g. /path/to/.pi/agent)")
repoRoot := flag.String("repo-root", "", "pi repo root (pi CLI cwd); defaults to two levels above agent-dir")
agentDir := flag.String("agent-dir", "", "SproutClaw agent directory (e.g. /path/to/.sproutclaw/agent)")
repoRoot := flag.String("repo-root", "", "SproutClaw repo root (CLI cwd); defaults to two levels above agent-dir")
dataDir := flag.String("data-dir", "", "sproutclaw-web data dir for SQLite (defaults to ./data)")
piCmd := flag.String("pi-cmd", "", "pi CLI command (may include args, e.g. \"node /path/tsx.mjs /path/cli.ts\")")
frontendDist := flag.String("frontend-dist", "", "path to frontend/dist (defaults to ../frontend/dist relative to binary)")
@@ -41,7 +41,7 @@ func New() *Config {
if *agentDir == "" {
cwd, _ := os.Getwd()
*agentDir = filepath.Join(cwd, ".pi", "agent")
*agentDir = filepath.Join(cwd, ".sproutclaw", "agent")
}
cfg := &Config{
@@ -50,7 +50,7 @@ func New() *Config {
RepoRoot: *repoRoot,
DataDir: *dataDir,
}
// repoRoot defaults to the directory two levels above agentDir (.pi/agent -> repo root)
// repoRoot defaults to the directory two levels above agentDir (.sproutclaw/agent -> repo root)
if cfg.RepoRoot == "" {
cfg.RepoRoot = filepath.Dir(filepath.Dir(*agentDir))
}

View File

@@ -0,0 +1,99 @@
package handlers
import (
"log"
"net/http"
"os"
"os/exec"
"runtime"
"strings"
"time"
"github.com/gin-gonic/gin"
)
const defaultSystemdUnit = "sproutclaw-web.service"
func handleRestart() gin.HandlerFunc {
return func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
go func() {
time.Sleep(300 * time.Millisecond)
if unit, underSystemd := systemdServiceUnit(); underSystemd {
log.Printf("[restart] restarting via systemctl: %s", unit)
cmd := exec.Command("systemctl", "restart", unit)
out, err := cmd.CombinedOutput()
if err != nil {
log.Printf("[restart] systemctl restart failed: %v: %s", err, strings.TrimSpace(string(out)))
}
return
}
restartByReexec()
}()
}
}
func restartByReexec() {
exe, err := os.Executable()
if err != nil {
log.Printf("[restart] get executable: %v", err)
os.Exit(1)
}
cmd := exec.Command(exe, os.Args[1:]...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
log.Printf("[restart] start new process: %v", err)
os.Exit(1)
}
os.Exit(0)
}
func systemdServiceUnit() (string, bool) {
if runtime.GOOS != "linux" {
return "", false
}
if os.Getenv("INVOCATION_ID") == "" && !inSystemdCgroup() {
return "", false
}
if unit := strings.TrimSpace(os.Getenv("SPROUTCLAW_WEB_SYSTEMD_UNIT")); unit != "" {
return normalizeSystemdUnit(unit), true
}
if unit := readSystemdUnitFromCgroup(); unit != "" {
return unit, true
}
return defaultSystemdUnit, true
}
func normalizeSystemdUnit(name string) string {
if strings.HasSuffix(name, ".service") {
return name
}
return name + ".service"
}
func inSystemdCgroup() bool {
data, err := os.ReadFile("/proc/self/cgroup")
if err != nil {
return false
}
return strings.Contains(string(data), ".service")
}
func readSystemdUnitFromCgroup() string {
data, err := os.ReadFile("/proc/self/cgroup")
if err != nil {
return ""
}
for _, line := range strings.Split(string(data), "\n") {
idx := strings.LastIndex(line, "/")
if idx < 0 {
continue
}
name := line[idx+1:]
if strings.HasSuffix(name, ".service") {
return name
}
}
return ""
}

View File

@@ -2,10 +2,8 @@ package handlers
import (
"fmt"
"log"
"net/http"
"os"
"os/exec"
"runtime"
"strings"
"sync"
@@ -37,28 +35,6 @@ func RegisterSettings(r *gin.RouterGroup, cfg *config.Config, database *db.DB, p
var processStartTime = time.Now()
func handleRestart() gin.HandlerFunc {
return func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
go func() {
time.Sleep(300 * time.Millisecond)
exe, err := os.Executable()
if err != nil {
log.Printf("[restart] get executable: %v", err)
os.Exit(1)
}
cmd := exec.Command(exe, os.Args[1:]...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
log.Printf("[restart] start new process: %v", err)
os.Exit(1)
}
os.Exit(0)
}()
}
}
func handleGetSettings(cfg *config.Config, database *db.DB) gin.HandlerFunc {
return func(c *gin.Context) {
systemPrompt, _ := services.ReadSystemPrompt(cfg.SystemPromptFile)

View File

@@ -80,7 +80,7 @@ func NewClient(piCmd string, piArgs []string, repoRoot, agentDir string, onExit
args := append(append([]string{}, piArgs...), "--mode", "rpc")
cmd := exec.Command(piCmd, args...)
cmd.Dir = repoRoot
cmd.Env = append(os.Environ(), fmt.Sprintf("PI_CODING_AGENT_DIR=%s", agentDir))
cmd.Env = append(os.Environ(), fmt.Sprintf("SPROUTCLAW_CODING_AGENT_DIR=%s", agentDir))
log.Printf("[sproutclaw-web] launching pi RPC: %s %s (cwd=%s)", piCmd, strings.Join(args, " "), repoRoot)

11
build.sh Executable file
View File

@@ -0,0 +1,11 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
OUT="$SCRIPT_DIR/backend/dist/sproutclaw-web-backend"
echo "[build] compiling Go backend -> $OUT"
cd "$SCRIPT_DIR/backend"
/usr/local/go/bin/go build -o "$OUT" ./cmd/server
chmod +x "$OUT"
echo "[build] done: $OUT"

View File

@@ -100,7 +100,7 @@ function extensionCategory(extension: ExtensionInfo): "local" | "npm" {
}
if (extension.source?.startsWith("npm:")) return "npm";
const pathHint = `${extension.path || ""} ${extension.location || ""}`.replace(/\\/g, "/");
if (pathHint.includes("/.pi/agent/npm/node_modules/")) return "npm";
if (pathHint.includes("/.sproutclaw/agent/npm/node_modules/")) return "npm";
return "local";
}
@@ -701,8 +701,13 @@ export function SettingsPage() {
} catch {
// 后端重启时连接断开属正常现象,忽略错误
}
// 轮询直到后端恢复
const deadline = Date.now() + 60_000;
const poll = async () => {
if (Date.now() > deadline) {
setRestarting(false);
setStatus("后端重启超时,请检查 sproutclaw-web 服务状态", "error");
return;
}
try {
await settingsApi.fetchSettings();
setStatus("后端已重启,正在刷新...", "ok");

View File

@@ -6,7 +6,7 @@ rem -------------------------------------------------------
cd /d "%~dp0backend"
echo Starting Go backend on port %BACKEND_PORT%...
go run ./cmd/server --port %BACKEND_PORT% --agent-dir "%SPROUTCLAW_DIR%\.pi\agent" --pi-cmd "node %SPROUTCLAW_DIR%\packages\coding-agent\src\cli.ts"
go run ./cmd/server --port %BACKEND_PORT% --agent-dir "%SPROUTCLAW_DIR%\.sproutclaw\agent" --pi-cmd "node %SPROUTCLAW_DIR%\packages\coding-agent\src\cli.ts"
echo.
pause >nul