Skip to content

Commit 83b8b44

Browse files
authored
engage: add -r flag to reload config of a running squadron (#103)
* engage: add -r flag to reload config of a running squadron A second `squadron engage` against a config dir that already has a running instance now errors out with a hint, instead of attempting a fork that would have failed later in daemon.Fork's IsRunning check. Passing `-r` / `--reload` to a second `engage` signals the running daemon over SIGHUP to re-read and validate its config. The reload runs through the same wsbridge.Client.ReloadConfig path the command center uses, so it inherits the validate-then-atomic-swap semantics: a broken config is rejected and the running daemon keeps its previous config. If `-r` is passed when nothing is running, the flag is noted and ignored and engage starts squadron normally. Includes tests for daemon.Reload covering: missing PID file, malformed PID file, stale PID, and actual SIGHUP delivery to a live process. * engage -r: notify command center on reload, harden PID checks, ginkgo tests - Forked daemon now removes its own PID file on graceful shutdown so the next `engage` doesn't see a stale PID after a non-`disengage` exit. - IsRunning gained a ps-based liveness check: the PID file is rejected (and cleaned up) unless the live process's command line contains `squadron` or `squadtest`. Prevents PID reuse from masking a dead daemon as alive. - Early IsRunning check in runEngage is now gated on !engageForeground so the forked child (which runs --foreground and would otherwise see the parent-written PID file pointing at itself) doesn't bail out as "already running". - wsbridge.Client.NotifyConfigReloaded(err) pushes an unsolicited TypeReloadConfigResult envelope to the command center after every SIGHUP-driven reload, reusing the existing wire message shape. Empty RequestID signals it's a one-way event, not a response. CC needs to handle this push (CC-side change is the user's call). - Tests converted to ginkgo/gomega: * internal/daemon/daemon_ginkgo_test.go covers ClearPid + Reload * wsbridge/notify_reload_test.go covers NotifyConfigReloaded (added to the existing internal-package suite so we don't add a second RunSpecs). * Docs: document `squadron engage -r` for config reload - cli/engage.mdx: add -r/--reload to the flags table, a Reloading configuration section showing success/failure output, the four-case behavior matrix, and what a successful reload actually does (re-reads HCL, swaps plugin set, pushes scheduler config, notifies command center, leaves in-flight missions untouched). - cli/disengage.mdx: cross-link to the reload flow so readers who reach for `disengage` to apply a config edit see the cheaper alternative. - compare/langgraph.mdx: replace "restart Squadron" with "squadron engage -r" in the plugin auto-build pitch. * Docs: trim engage -r section
1 parent 7f0d988 commit 83b8b44

9 files changed

Lines changed: 352 additions & 10 deletions

File tree

cmd/engage.go

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ var (
4040
engageCCPort int
4141
engageAutoInit bool
4242
engageForeground bool
43+
engageReload bool
4344
)
4445

4546
const (
@@ -82,6 +83,7 @@ func init() {
8283
engageCmd.Flags().IntVar(&engageCCPort, "cc-port", 8080, "Port for the command center")
8384
engageCmd.Flags().BoolVar(&engageAutoInit, "init", false, "Auto-initialize Squadron if not already initialized")
8485
engageCmd.Flags().BoolVar(&engageForeground, "foreground", false, "Run in foreground (default: run as background service)")
86+
engageCmd.Flags().BoolVarP(&engageReload, "reload", "r", false, "Reload the config of an already-running squadron (no-op if not running)")
8587
}
8688

8789
func runEngage(cmd *cobra.Command, args []string) {
@@ -95,6 +97,24 @@ func runEngage(cmd *cobra.Command, args []string) {
9597
os.Exit(1)
9698
}
9799

100+
// Only the user-invoked parent process should check IsRunning. The forked
101+
// child runs with --foreground and would otherwise see the PID file the
102+
// parent just wrote for it and bail out as "already running".
103+
if !engageForeground {
104+
running, pid := daemon.IsRunning(engageConfigPath)
105+
switch {
106+
case running && engageReload:
107+
reloadRunningSquadron(pid)
108+
return
109+
case running:
110+
fmt.Fprintf(os.Stderr, "Error: squadron is already running (PID %d).\n", pid)
111+
fmt.Fprintln(os.Stderr, "Use 'squadron engage -r' to reload the config, or 'squadron disengage' to stop it.")
112+
os.Exit(1)
113+
case engageReload:
114+
fmt.Println("Squadron is not running — ignoring -r and starting it now.")
115+
}
116+
}
117+
98118
if warning, err := validateConfigDir(engageConfigPath); err != nil {
99119
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
100120
os.Exit(1)
@@ -191,6 +211,13 @@ func runEngage(cmd *cobra.Command, args []string) {
191211

192212
daemon.ClearReady(engageConfigPath)
193213

214+
// Forked daemons need to clean up the PID file when they exit so the next
215+
// `engage` doesn't see a stale PID. (`disengage` removes it on its own
216+
// path, but a crash or signal-driven shutdown would otherwise leave it.)
217+
if os.Getenv("SQUADRON_FORKED") == "1" {
218+
defer daemon.ClearPid(engageConfigPath)
219+
}
220+
194221
// Resolve the vault passphrase once at startup and keep it in memory for
195222
// all later var operations on this process.
196223
if config.IsVaultInitialized() {
@@ -392,6 +419,27 @@ func runEngage(cmd *cobra.Command, args []string) {
392419
client.Close()
393420
}()
394421

422+
reloads := make(chan os.Signal, 1)
423+
signal.Notify(reloads, syscall.SIGHUP)
424+
go func() {
425+
for {
426+
select {
427+
case <-shutdown:
428+
return
429+
case <-reloads:
430+
log.Println("SIGHUP received — reloading config")
431+
err := client.ReloadConfig()
432+
if err != nil {
433+
log.Printf("Config reload failed: %v", err)
434+
daemon.SignalFailed(engageConfigPath, err)
435+
} else {
436+
log.Println("Config reload succeeded")
437+
}
438+
client.NotifyConfigReloaded(err)
439+
}
440+
}
441+
}()
442+
395443
// Periodic sweep of expired per-run ephemeral memory directories.
396444
// Runs hourly; walks the filesystem so the live config isn't needed.
397445
go runScratchpadCleanupLoop(shutdown)
@@ -503,6 +551,35 @@ func isContainer() bool {
503551
return os.Getenv("SQUADRON_CONTAINER") == "1"
504552
}
505553

554+
func reloadRunningSquadron(pid int) {
555+
absConfigPath, err := filepath.Abs(engageConfigPath)
556+
if err != nil {
557+
fmt.Fprintf(os.Stderr, "Error resolving config path: %v\n", err)
558+
os.Exit(1)
559+
}
560+
561+
fmt.Printf("Squadron is already running (PID %d). Reloading config from %s...\n", pid, absConfigPath)
562+
563+
daemon.ClearReady(absConfigPath)
564+
565+
if _, err := daemon.Reload(absConfigPath); err != nil {
566+
fmt.Fprintf(os.Stderr, "Error signaling squadron (PID %d): %v\n", pid, err)
567+
os.Exit(1)
568+
}
569+
570+
sp := startSpinner("Validating and applying")
571+
ready := daemon.WaitReady(absConfigPath, 30*time.Second, 500*time.Millisecond)
572+
sp.Stop()
573+
574+
if !ready.OK {
575+
fmt.Fprintf(os.Stderr, "Config reload failed: %s\n", ready.Error)
576+
fmt.Fprintf(os.Stderr, "Squadron is still running with the previous config (PID %d). Fix the error above and re-run 'squadron engage'.\n", pid)
577+
os.Exit(1)
578+
}
579+
580+
fmt.Println("Config reloaded successfully.")
581+
}
582+
506583
func hasHCLFiles(configPath string) bool {
507584
info, err := os.Stat(configPath)
508585
if err != nil {

docs/content/cli/disengage.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,4 +39,4 @@ squadron disengage -c /path/to/other-project
3939

4040
## See Also
4141

42-
- [engage](/cli/engage) — Start the daemon
42+
- [engage](/cli/engage) — Start the daemon, or reload its config with `-r`

docs/content/cli/engage.mdx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ Multiple projects can run simultaneously on the same host — each gets its own
3939
| Flag | Description |
4040
|------|-------------|
4141
| `-c, --config` | Path to config directory (default: `.`) |
42+
| `-r, --reload` | Reload the config of an already-running Squadron (see [Reloading configuration](#reloading-configuration)) |
4243
| `--headless` | Skip launching the local command center UI |
4344
| `--cc-port` | Port for the local command center (default: `8080`) |
4445
| `--foreground` | Run in the terminal instead of forking to background |
@@ -89,6 +90,16 @@ Keeps the process attached to the terminal. Useful for debugging or when running
8990
squadron engage --cc-port 9090
9091
```
9192

93+
## Reloading configuration
94+
95+
After editing your HCL files, reload the running daemon in place:
96+
97+
```bash
98+
squadron engage -r
99+
```
100+
101+
The new config is validated first; if it's bad, the running daemon keeps its previous config and the command prints the error. In-flight missions and chat sessions keep running on the config they started with.
102+
92103
## Stopping
93104

94105
```bash

docs/content/compare/langgraph.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ Why plugins matter relative to LangGraph's "tools are Python functions in the sa
122122

123123
- **Two languages, picked per problem.** Go for performance-critical or systems-level tools (a browser controller, a network scanner, anything CPU-heavy or needing static binary distribution). Python for things that lean on the existing PyPI ecosystem (a pandas pipeline, a model adapter, a domain SDK).
124124
- **Process isolation.** A misbehaving plugin can't crash the runtime — gRPC failure mode is a clean error returned to the agent, not a Python exception unwinding through your orchestrator.
125-
- **Auto-build from source.** Edit `./plugin_scraper/main.go`, restart Squadron, and the plugin rebuilds. Content-hash caching skips the rebuild when nothing changed. No `pip install -e .` cycle, no Docker layer to rebuild.
125+
- **Auto-build from source.** Edit `./plugin_scraper/main.go`, reload Squadron with `squadron engage -r`, and the plugin rebuilds. Content-hash caching skips the rebuild when nothing changed. No `pip install -e .` cycle, no Docker layer to rebuild.
126126
- **Stateful across tasks.** Plugins are cached globally for the lifetime of the process. A Playwright plugin can open a browser in task 1 and reuse it in task 5 — the runtime tracks the plugin connection, not the per-task call.
127127
- **Typed schemas.** Each plugin declares tool input/output schemas; Squadron uses those for native LLM function-calling and for output validation.
128128
- **Distributable.** Plugins compile to a single binary (Go) or a venv (Python) and are publishable as GitHub releases. Other Squadron configs reference them via `source = "github.com/owner/repo"` and Squadron auto-installs.

internal/daemon/daemon.go

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,12 @@ func ClearReady(configPath string) {
5151
os.Remove(ReadyFilePath(configPath))
5252
}
5353

54+
// ClearPid removes the PID file. Called by the forked daemon on graceful
55+
// shutdown so the next `engage` doesn't see a stale PID.
56+
func ClearPid(configPath string) {
57+
os.Remove(PidFilePath(configPath))
58+
}
59+
5460
// CleanupFailedFork removes the PID and ready files for a fork where the child
5561
// signaled failure (and has already exited on its own).
5662
func CleanupFailedFork(configPath string) {
@@ -180,6 +186,36 @@ func Fork(configPath string, extraFlags []string) (int, error) {
180186
return pid, nil
181187
}
182188

189+
// Reload sends SIGHUP to the running daemon to trigger a config reload.
190+
func Reload(configPath string) (int, error) {
191+
absConfig, err := filepath.Abs(configPath)
192+
if err != nil {
193+
return 0, fmt.Errorf("could not resolve config path: %w", err)
194+
}
195+
196+
pidPath := PidFilePath(absConfig)
197+
data, err := os.ReadFile(pidPath)
198+
if err != nil {
199+
return 0, fmt.Errorf("no PID file found — squadron may not be running")
200+
}
201+
202+
pid, err := strconv.Atoi(strings.TrimSpace(string(data)))
203+
if err != nil {
204+
return 0, fmt.Errorf("invalid PID file")
205+
}
206+
207+
process, err := os.FindProcess(pid)
208+
if err != nil {
209+
return 0, fmt.Errorf("process %d not found", pid)
210+
}
211+
212+
if err := process.Signal(syscall.SIGHUP); err != nil {
213+
return 0, fmt.Errorf("could not signal process %d: %w", pid, err)
214+
}
215+
216+
return pid, nil
217+
}
218+
183219
// Stop reads the PID file and gracefully stops the background process.
184220
func Stop(configPath string) error {
185221
absConfig, err := filepath.Abs(configPath)
@@ -230,6 +266,8 @@ func Stop(configPath string) error {
230266
}
231267

232268
// IsRunning checks if a Squadron process is running for the given config path.
269+
// Verifies via ps that the PID belongs to a squadron-ish binary, so a recycled
270+
// PID from an unrelated process isn't mistaken for a live daemon.
233271
func IsRunning(configPath string) (bool, int) {
234272
absConfig, _ := filepath.Abs(configPath)
235273
pidPath := PidFilePath(absConfig)
@@ -249,16 +287,36 @@ func IsRunning(configPath string) (bool, int) {
249287
return false, 0
250288
}
251289

252-
// Check if process is actually alive
253290
if err := process.Signal(syscall.Signal(0)); err != nil {
254-
// Stale PID file — clean up
291+
os.Remove(pidPath)
292+
return false, 0
293+
}
294+
295+
if !isSquadronProcess(pid) {
255296
os.Remove(pidPath)
256297
return false, 0
257298
}
258299

259300
return true, pid
260301
}
261302

303+
// isSquadronProcess returns true if the given PID belongs to a squadron-like
304+
// binary (matches "squadron" or "squadtest" in its command line). Falls back
305+
// to permissive true if ps is unavailable.
306+
func isSquadronProcess(pid int) bool {
307+
out, err := exec.Command("ps", "-p", strconv.Itoa(pid), "-o", "command=").Output()
308+
if err != nil {
309+
// ps not available (unusual on macOS/Linux) — skip the check rather
310+
// than refuse to reload.
311+
return true
312+
}
313+
cmd := strings.ToLower(strings.TrimSpace(string(out)))
314+
if cmd == "" {
315+
return false
316+
}
317+
return strings.Contains(cmd, "squadron") || strings.Contains(cmd, "squadtest")
318+
}
319+
262320
// resolveConfigDir returns the directory component of a config path.
263321
func resolveConfigDir(configPath string) string {
264322
info, err := os.Stat(configPath)
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
package daemon
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"os/signal"
7+
"path/filepath"
8+
"syscall"
9+
"testing"
10+
"time"
11+
12+
. "github.com/onsi/ginkgo/v2"
13+
. "github.com/onsi/gomega"
14+
)
15+
16+
func TestDaemonSpecs(t *testing.T) {
17+
RegisterFailHandler(Fail)
18+
RunSpecs(t, "Daemon Suite")
19+
}
20+
21+
var _ = Describe("ClearPid", func() {
22+
It("removes the PID file", func() {
23+
dir := GinkgoT().TempDir()
24+
Expect(os.MkdirAll(filepath.Join(dir, ".squadron"), 0755)).To(Succeed())
25+
Expect(os.WriteFile(PidFilePath(dir), []byte("12345"), 0644)).To(Succeed())
26+
27+
ClearPid(dir)
28+
29+
_, err := os.Stat(PidFilePath(dir))
30+
Expect(os.IsNotExist(err)).To(BeTrue(), "PID file should be gone after ClearPid")
31+
})
32+
33+
It("is a no-op when no PID file exists", func() {
34+
Expect(func() { ClearPid(GinkgoT().TempDir()) }).NotTo(Panic())
35+
})
36+
})
37+
38+
var _ = Describe("Reload", func() {
39+
Context("when the PID file is missing", func() {
40+
It("returns an error", func() {
41+
_, err := Reload(GinkgoT().TempDir())
42+
Expect(err).To(HaveOccurred())
43+
})
44+
})
45+
46+
Context("when the PID file is malformed", func() {
47+
It("returns an error", func() {
48+
dir := GinkgoT().TempDir()
49+
Expect(os.MkdirAll(filepath.Join(dir, ".squadron"), 0755)).To(Succeed())
50+
Expect(os.WriteFile(PidFilePath(dir), []byte("not-a-pid"), 0644)).To(Succeed())
51+
52+
_, err := Reload(dir)
53+
Expect(err).To(HaveOccurred())
54+
})
55+
})
56+
57+
Context("when the target process does not exist", func() {
58+
It("returns an error", func() {
59+
dir := GinkgoT().TempDir()
60+
Expect(os.MkdirAll(filepath.Join(dir, ".squadron"), 0755)).To(Succeed())
61+
// A high PID that almost certainly doesn't exist.
62+
Expect(os.WriteFile(PidFilePath(dir), []byte("999999"), 0644)).To(Succeed())
63+
64+
_, err := Reload(dir)
65+
Expect(err).To(HaveOccurred())
66+
})
67+
})
68+
69+
Context("when the target process is alive", func() {
70+
It("delivers SIGHUP to the recorded PID", func() {
71+
dir := GinkgoT().TempDir()
72+
Expect(os.MkdirAll(filepath.Join(dir, ".squadron"), 0755)).To(Succeed())
73+
74+
pid := os.Getpid()
75+
Expect(os.WriteFile(PidFilePath(dir), []byte(fmt.Sprintf("%d", pid)), 0644)).To(Succeed())
76+
77+
sigs := make(chan os.Signal, 1)
78+
signal.Notify(sigs, syscall.SIGHUP)
79+
DeferCleanup(func() { signal.Stop(sigs) })
80+
81+
gotPID, err := Reload(dir)
82+
Expect(err).NotTo(HaveOccurred())
83+
Expect(gotPID).To(Equal(pid))
84+
85+
Eventually(sigs, 2*time.Second).Should(Receive())
86+
})
87+
})
88+
})

internal/daemon/daemon_test.go

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -205,18 +205,20 @@ func TestIsRunning_LiveProcess(t *testing.T) {
205205
t.Fatal(err)
206206
}
207207

208-
// The current test process is definitely alive.
208+
// The current test process is definitely alive. Its command line will be
209+
// the `go test` binary (e.g. /tmp/.../daemon.test) — not "squadron", so
210+
// IsRunning should reject it.
209211
pid := os.Getpid()
210212
if err := os.WriteFile(PidFilePath(dir), []byte(fmt.Sprintf("%d", pid)), 0644); err != nil {
211213
t.Fatal(err)
212214
}
213215

214-
running, got := IsRunning(dir)
215-
if !running {
216-
t.Error("IsRunning false for a live process")
216+
running, _ := IsRunning(dir)
217+
if running {
218+
t.Error("IsRunning should reject a live non-squadron PID (PID-reuse protection)")
217219
}
218-
if got != pid {
219-
t.Errorf("pid = %d, want %d", got, pid)
220+
if _, err := os.Stat(PidFilePath(dir)); !os.IsNotExist(err) {
221+
t.Errorf("PID file should be cleaned up after rejection")
220222
}
221223
}
222224

0 commit comments

Comments
 (0)