sensorium-mcp 3.0.1 → 3.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/dashboard/routes/data.d.ts +1 -0
- package/dist/dashboard/routes/data.d.ts.map +1 -1
- package/dist/dashboard/routes/data.js +11 -1
- package/dist/dashboard/routes/data.js.map +1 -1
- package/dist/dashboard/routes.d.ts.map +1 -1
- package/dist/dashboard/routes.js +3 -1
- package/dist/dashboard/routes.js.map +1 -1
- package/dist/data/memory/thread-registry.js +1 -1
- package/dist/data/memory/thread-registry.js.map +1 -1
- package/dist/http-server.d.ts.map +1 -1
- package/dist/http-server.js +9 -1
- package/dist/http-server.js.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/services/agent-spawn.service.d.ts.map +1 -1
- package/dist/services/agent-spawn.service.js +12 -5
- package/dist/services/agent-spawn.service.js.map +1 -1
- package/dist/services/reconnect-snapshot.service.d.ts +30 -0
- package/dist/services/reconnect-snapshot.service.d.ts.map +1 -0
- package/dist/services/reconnect-snapshot.service.js +83 -0
- package/dist/services/reconnect-snapshot.service.js.map +1 -0
- package/dist/services/thread-lifecycle.service.js +1 -1
- package/dist/services/thread-lifecycle.service.js.map +1 -1
- package/dist/services/worker-cleanup.service.d.ts.map +1 -1
- package/dist/services/worker-cleanup.service.js +30 -7
- package/dist/services/worker-cleanup.service.js.map +1 -1
- package/dist/sessions.d.ts +5 -0
- package/dist/sessions.d.ts.map +1 -1
- package/dist/sessions.js +7 -0
- package/dist/sessions.js.map +1 -1
- package/dist/stdio-server.d.ts.map +1 -1
- package/dist/stdio-server.js +7 -1
- package/dist/stdio-server.js.map +1 -1
- package/dist/tools/delegate-tool.d.ts.map +1 -1
- package/dist/tools/delegate-tool.js +1 -0
- package/dist/tools/delegate-tool.js.map +1 -1
- package/dist/tools/start-session-tool.d.ts.map +1 -1
- package/dist/tools/start-session-tool.js +9 -8
- package/dist/tools/start-session-tool.js.map +1 -1
- package/package.json +1 -1
- package/supervisor/config.go +1 -1
- package/supervisor/health.go +38 -12
- package/supervisor/keeper.go +27 -26
- package/supervisor/lock.go +1 -0
- package/supervisor/main.go +13 -4
- package/supervisor/process.go +29 -0
- package/supervisor/service_windows.go +98 -120
- package/supervisor/updater.go +12 -11
package/supervisor/health.go
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
package main
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
|
+
"bytes"
|
|
4
5
|
"context"
|
|
5
6
|
"encoding/json"
|
|
6
7
|
"fmt"
|
|
@@ -16,25 +17,27 @@ type MCPClient struct {
|
|
|
16
17
|
Secret string
|
|
17
18
|
Client *http.Client
|
|
18
19
|
Log *Logger
|
|
20
|
+
headers map[string]string
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
func NewMCPClient(port int, secret string) *MCPClient {
|
|
24
|
+
h := map[string]string{
|
|
25
|
+
"Content-Type": "application/json",
|
|
26
|
+
"Accept": "application/json",
|
|
27
|
+
}
|
|
28
|
+
if secret != "" {
|
|
29
|
+
h["Authorization"] = "Bearer " + secret
|
|
30
|
+
}
|
|
22
31
|
return &MCPClient{
|
|
23
32
|
BaseURL: fmt.Sprintf("http://127.0.0.1:%d", port),
|
|
24
33
|
Secret: secret,
|
|
25
34
|
Client: &http.Client{Timeout: 10 * time.Second},
|
|
35
|
+
headers: h,
|
|
26
36
|
}
|
|
27
37
|
}
|
|
28
38
|
|
|
29
39
|
func (m *MCPClient) authHeaders() map[string]string {
|
|
30
|
-
|
|
31
|
-
"Content-Type": "application/json",
|
|
32
|
-
"Accept": "application/json",
|
|
33
|
-
}
|
|
34
|
-
if m.Secret != "" {
|
|
35
|
-
h["Authorization"] = "Bearer " + m.Secret
|
|
36
|
-
}
|
|
37
|
-
return h
|
|
40
|
+
return m.headers
|
|
38
41
|
}
|
|
39
42
|
|
|
40
43
|
func (m *MCPClient) doReq(ctx context.Context, method, path string, body any) (*http.Response, error) {
|
|
@@ -44,7 +47,7 @@ func (m *MCPClient) doReq(ctx context.Context, method, path string, body any) (*
|
|
|
44
47
|
if err != nil {
|
|
45
48
|
return nil, err
|
|
46
49
|
}
|
|
47
|
-
bodyReader =
|
|
50
|
+
bodyReader = bytes.NewReader(data)
|
|
48
51
|
}
|
|
49
52
|
|
|
50
53
|
req, err := http.NewRequestWithContext(ctx, method, m.BaseURL+path, bodyReader)
|
|
@@ -96,6 +99,26 @@ func (m *MCPClient) WaitForReady(ctx context.Context, pollInterval, timeout time
|
|
|
96
99
|
}
|
|
97
100
|
}
|
|
98
101
|
|
|
102
|
+
// PrepareShutdown asks the MCP server to write a reconnect snapshot before being killed.
|
|
103
|
+
// Best-effort: returns false on any error so the caller can proceed with the kill.
|
|
104
|
+
func (m *MCPClient) PrepareShutdown(ctx context.Context) bool {
|
|
105
|
+
ctx2, cancel := context.WithTimeout(ctx, 3*time.Second)
|
|
106
|
+
defer cancel()
|
|
107
|
+
resp, err := m.doReq(ctx2, "POST", "/api/prepare-shutdown", nil)
|
|
108
|
+
if err != nil {
|
|
109
|
+
if m.Log != nil {
|
|
110
|
+
m.Log.Debug("PrepareShutdown: POST /api/prepare-shutdown failed: %v", err)
|
|
111
|
+
}
|
|
112
|
+
return false
|
|
113
|
+
}
|
|
114
|
+
defer resp.Body.Close()
|
|
115
|
+
ok := resp.StatusCode == 200
|
|
116
|
+
if m.Log != nil {
|
|
117
|
+
m.Log.Info("PrepareShutdown: POST /api/prepare-shutdown => %d", resp.StatusCode)
|
|
118
|
+
}
|
|
119
|
+
return ok
|
|
120
|
+
}
|
|
121
|
+
|
|
99
122
|
// GetRootThreads fetches the list of root threads from the server.
|
|
100
123
|
func (m *MCPClient) GetRootThreads(ctx context.Context) ([]map[string]any, error) {
|
|
101
124
|
return m.fetchThreadList(ctx, "/api/threads/roots")
|
|
@@ -292,7 +315,7 @@ func (m *MCPClient) OpenMCPSession(ctx context.Context) (string, error) {
|
|
|
292
315
|
if err != nil {
|
|
293
316
|
return sessionID, nil // session created, notification failed — non-fatal
|
|
294
317
|
}
|
|
295
|
-
notifReq.Body = io.NopCloser(
|
|
318
|
+
notifReq.Body = io.NopCloser(bytes.NewReader(data))
|
|
296
319
|
for k, v := range m.authHeaders() {
|
|
297
320
|
notifReq.Header.Set(k, v)
|
|
298
321
|
}
|
|
@@ -357,8 +380,11 @@ func (m *MCPClient) CallStartThread(ctx context.Context, sessionID string, threa
|
|
|
357
380
|
if err != nil {
|
|
358
381
|
return "", err
|
|
359
382
|
}
|
|
360
|
-
data,
|
|
361
|
-
|
|
383
|
+
data, err := json.Marshal(payload)
|
|
384
|
+
if err != nil {
|
|
385
|
+
return "", err
|
|
386
|
+
}
|
|
387
|
+
req.Body = io.NopCloser(bytes.NewReader(data))
|
|
362
388
|
for k, v := range m.authHeaders() {
|
|
363
389
|
req.Header.Set(k, v)
|
|
364
390
|
}
|
package/supervisor/keeper.go
CHANGED
|
@@ -114,6 +114,7 @@ func (k *Keeper) run(ctx context.Context) {
|
|
|
114
114
|
k.log.Warn("Thread %d (worker %d) is stuck (no heartbeat for %v) — restarting", k.cfg.ThreadID, activeThreadID, k.global.StuckThreshold)
|
|
115
115
|
// Kill via MCP API, then fall through to restart
|
|
116
116
|
k.killThread(ctx, activeThreadID)
|
|
117
|
+
retryCount = 0
|
|
117
118
|
activeThreadID = k.cfg.ThreadID // reset — will get new worker ID on restart
|
|
118
119
|
} else {
|
|
119
120
|
// Healthy — reset counters
|
|
@@ -129,7 +130,31 @@ func (k *Keeper) run(ctx context.Context) {
|
|
|
129
130
|
}
|
|
130
131
|
}
|
|
131
132
|
|
|
132
|
-
// Thread is not running (or was stuck and killed)
|
|
133
|
+
// Thread is not running (or was stuck and killed).
|
|
134
|
+
// Detect fast-exit: if the previous successful start was recent, the thread exited too quickly.
|
|
135
|
+
if !lastStartTime.IsZero() && time.Since(lastStartTime) < k.global.FastExitThreshold {
|
|
136
|
+
fastExitCount++
|
|
137
|
+
if fastExitCount >= k.global.FastExitMaxCount {
|
|
138
|
+
cooldown := time.Duration(float64(k.global.FastExitBaseCooldown) * math.Pow(2, float64(fastExitEscalation)))
|
|
139
|
+
if cooldown > k.global.FastExitMaxCooldown {
|
|
140
|
+
cooldown = k.global.FastExitMaxCooldown
|
|
141
|
+
}
|
|
142
|
+
k.log.Warn("Thread %d: %d consecutive fast exits — backing off %v", k.cfg.ThreadID, fastExitCount, cooldown)
|
|
143
|
+
if k.onDeath != nil {
|
|
144
|
+
k.onDeath(k.cfg.ThreadID, k.cfg.SessionName+" (repeated fast exits — check credits/API key)")
|
|
145
|
+
}
|
|
146
|
+
fastExitEscalation++
|
|
147
|
+
k.sleep(ctx, cooldown)
|
|
148
|
+
fastExitCount = 0
|
|
149
|
+
retryCount = 0
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
} else if !lastStartTime.IsZero() {
|
|
153
|
+
// Previous start was long ago — not a fast exit pattern, reset counters
|
|
154
|
+
fastExitCount = 0
|
|
155
|
+
fastExitEscalation = 0
|
|
156
|
+
}
|
|
157
|
+
|
|
133
158
|
if retryCount >= k.cfg.MaxRetries {
|
|
134
159
|
cooldown := k.global.KeeperCooldown
|
|
135
160
|
if k.cfg.CooldownMs > 0 {
|
|
@@ -157,10 +182,10 @@ func (k *Keeper) run(ctx context.Context) {
|
|
|
157
182
|
return
|
|
158
183
|
}
|
|
159
184
|
|
|
160
|
-
lastStartTime = time.Now()
|
|
161
185
|
ok, workerID := k.callStartThread(ctx)
|
|
162
186
|
|
|
163
187
|
if ok {
|
|
188
|
+
lastStartTime = time.Now()
|
|
164
189
|
if workerID > 0 {
|
|
165
190
|
activeThreadID = workerID
|
|
166
191
|
k.log.Info("Thread %d start_thread succeeded (worker %d)", k.cfg.ThreadID, workerID)
|
|
@@ -168,31 +193,7 @@ func (k *Keeper) run(ctx context.Context) {
|
|
|
168
193
|
k.log.Info("Thread %d start_thread succeeded", k.cfg.ThreadID)
|
|
169
194
|
}
|
|
170
195
|
retryCount = 0
|
|
171
|
-
// Check for fast exit on next check
|
|
172
196
|
} else {
|
|
173
|
-
// Check for fast exit pattern
|
|
174
|
-
if !lastStartTime.IsZero() && time.Since(lastStartTime) < k.global.FastExitThreshold {
|
|
175
|
-
fastExitCount++
|
|
176
|
-
if fastExitCount >= k.global.FastExitMaxCount {
|
|
177
|
-
cooldown := time.Duration(float64(k.global.FastExitBaseCooldown) * math.Pow(2, float64(fastExitEscalation)))
|
|
178
|
-
if cooldown > k.global.FastExitMaxCooldown {
|
|
179
|
-
cooldown = k.global.FastExitMaxCooldown
|
|
180
|
-
}
|
|
181
|
-
k.log.Warn("Thread %d: %d consecutive fast exits — backing off %v", k.cfg.ThreadID, fastExitCount, cooldown)
|
|
182
|
-
if k.onDeath != nil {
|
|
183
|
-
k.onDeath(k.cfg.ThreadID, k.cfg.SessionName+" (repeated fast exits — check credits/API key)")
|
|
184
|
-
}
|
|
185
|
-
fastExitEscalation++
|
|
186
|
-
k.sleep(ctx, cooldown)
|
|
187
|
-
fastExitCount = 0
|
|
188
|
-
retryCount = 0
|
|
189
|
-
return
|
|
190
|
-
}
|
|
191
|
-
} else {
|
|
192
|
-
fastExitCount = 0
|
|
193
|
-
fastExitEscalation = 0
|
|
194
|
-
}
|
|
195
|
-
|
|
196
197
|
retryCount++
|
|
197
198
|
delay := k.backoff(retryCount)
|
|
198
199
|
k.log.Info("Backing off %v before next attempt", delay)
|
package/supervisor/lock.go
CHANGED
package/supervisor/main.go
CHANGED
|
@@ -166,10 +166,16 @@ func runSupervisor(runningAsService bool) error {
|
|
|
166
166
|
mcp := NewMCPClient(cfg.MCPHttpPort, cfg.MCPHttpSecret)
|
|
167
167
|
mcp.Log = log
|
|
168
168
|
|
|
169
|
-
//
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
// Kill orphan
|
|
169
|
+
// Kill orphan thread processes from previous runs, then clean PID files
|
|
170
|
+
KillOrphanThreads(cfg.Paths.PIDsDir, log)
|
|
171
|
+
|
|
172
|
+
// Kill orphan MCP server from previous run
|
|
173
|
+
if oldPid, err := ReadPIDFile(cfg.Paths.ServerPID); err == nil && oldPid > 0 && IsProcessAlive(oldPid) {
|
|
174
|
+
log.Info("Killing orphan MCP server (PID %d) from previous run", oldPid)
|
|
175
|
+
_ = KillProcess(oldPid, log)
|
|
176
|
+
time.Sleep(1 * time.Second) // allow port to release
|
|
177
|
+
}
|
|
178
|
+
_ = os.Remove(cfg.Paths.ServerPID)
|
|
173
179
|
KillByPort(cfg.MCPHttpPort, log)
|
|
174
180
|
|
|
175
181
|
// Spawn MCP server
|
|
@@ -369,6 +375,9 @@ func runSupervisor(runningAsService bool) error {
|
|
|
369
375
|
<-keeperPollerDone
|
|
370
376
|
<-healthDone
|
|
371
377
|
|
|
378
|
+
// Ask MCP to write reconnect snapshot before killing it
|
|
379
|
+
mcp.PrepareShutdown(context.Background())
|
|
380
|
+
|
|
372
381
|
// Kill server process
|
|
373
382
|
pid, err := ReadPIDFile(cfg.Paths.ServerPID)
|
|
374
383
|
if err == nil && pid > 0 {
|
package/supervisor/process.go
CHANGED
|
@@ -242,6 +242,35 @@ func ListThreadPIDs(pidsDir string) map[string]int {
|
|
|
242
242
|
return result
|
|
243
243
|
}
|
|
244
244
|
|
|
245
|
+
// KillOrphanThreads kills any alive processes listed in PID files (leftovers from
|
|
246
|
+
// a previous supervisor run) and then removes the PID files. Called on startup
|
|
247
|
+
// before the MCP server is launched, so all thread PIDs are guaranteed to be stale.
|
|
248
|
+
func KillOrphanThreads(pidsDir string, log *Logger) {
|
|
249
|
+
pids := ListThreadPIDs(pidsDir)
|
|
250
|
+
if len(pids) == 0 {
|
|
251
|
+
log.Debug("KillOrphanThreads: no PID files found")
|
|
252
|
+
return
|
|
253
|
+
}
|
|
254
|
+
log.Info("KillOrphanThreads: checking %d PID files for orphan processes", len(pids))
|
|
255
|
+
killed := 0
|
|
256
|
+
for threadID, pid := range pids {
|
|
257
|
+
path := filepath.Join(pidsDir, threadID+".pid")
|
|
258
|
+
if IsProcessAlive(pid) {
|
|
259
|
+
log.Info("Killing orphan thread %s (PID %d)", threadID, pid)
|
|
260
|
+
if err := KillProcess(pid, log); err != nil {
|
|
261
|
+
log.Warn("Failed to kill orphan PID %d: %v", pid, err)
|
|
262
|
+
}
|
|
263
|
+
killed++
|
|
264
|
+
}
|
|
265
|
+
_ = os.Remove(path)
|
|
266
|
+
}
|
|
267
|
+
if killed > 0 {
|
|
268
|
+
log.Info("KillOrphanThreads: killed %d orphan processes, cleaned %d PID files", killed, len(pids))
|
|
269
|
+
} else {
|
|
270
|
+
log.Info("KillOrphanThreads: no orphans found, cleaned %d stale PID files", len(pids))
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
245
274
|
// CleanStalePIDs removes PID files for processes that are no longer running.
|
|
246
275
|
func CleanStalePIDs(pidsDir string, log *Logger) {
|
|
247
276
|
pids := ListThreadPIDs(pidsDir)
|
|
@@ -5,6 +5,7 @@ package main
|
|
|
5
5
|
import (
|
|
6
6
|
"fmt"
|
|
7
7
|
"os"
|
|
8
|
+
"path/filepath"
|
|
8
9
|
"time"
|
|
9
10
|
|
|
10
11
|
"golang.org/x/sys/windows/svc"
|
|
@@ -58,159 +59,136 @@ func runAsService() error {
|
|
|
58
59
|
return svc.Run(serviceName, &supervisorService{})
|
|
59
60
|
}
|
|
60
61
|
|
|
61
|
-
func
|
|
62
|
+
func withServiceManager(fn func(*mgr.Mgr) error) error {
|
|
62
63
|
m, err := mgr.Connect()
|
|
63
64
|
if err != nil {
|
|
64
|
-
return
|
|
65
|
+
return err
|
|
65
66
|
}
|
|
66
67
|
defer m.Disconnect()
|
|
68
|
+
return fn(m)
|
|
69
|
+
}
|
|
67
70
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
s.
|
|
71
|
-
|
|
72
|
-
|
|
71
|
+
func installService(exePath, serviceUser, servicePassword string) error {
|
|
72
|
+
return withServiceManager(func(m *mgr.Mgr) error {
|
|
73
|
+
s, err := m.OpenService(serviceName)
|
|
74
|
+
if err == nil {
|
|
75
|
+
s.Close()
|
|
76
|
+
return fmt.Errorf("install failed: service %q already exists", serviceName)
|
|
77
|
+
}
|
|
73
78
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
79
|
+
cfg := mgr.Config{
|
|
80
|
+
DisplayName: serviceDisplay,
|
|
81
|
+
Description: serviceDesc,
|
|
82
|
+
StartType: mgr.StartAutomatic,
|
|
83
|
+
DelayedAutoStart: true,
|
|
84
|
+
}
|
|
85
|
+
if serviceUser != "" {
|
|
86
|
+
cfg.ServiceStartName = serviceUser
|
|
87
|
+
cfg.Password = servicePassword
|
|
88
|
+
if servicePassword == "" {
|
|
89
|
+
fmt.Printf("Installing service as passwordless identity %q\n", serviceUser)
|
|
90
|
+
} else {
|
|
91
|
+
fmt.Printf("Installing service as user %q\n", serviceUser)
|
|
92
|
+
}
|
|
85
93
|
} else {
|
|
86
|
-
fmt.
|
|
94
|
+
fmt.Println("Installing service as LocalSystem (default). Use -service-user to run as a specific user account.")
|
|
87
95
|
}
|
|
88
|
-
} else {
|
|
89
|
-
fmt.Println("Installing service as LocalSystem (default). Use -service-user to run as a specific user account.")
|
|
90
|
-
}
|
|
91
96
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
+
s, err = m.CreateService(serviceName, exePath, cfg)
|
|
98
|
+
if err != nil {
|
|
99
|
+
return fmt.Errorf("install failed: create service: %w", err)
|
|
100
|
+
}
|
|
101
|
+
defer s.Close()
|
|
97
102
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
103
|
+
fmt.Printf("Service %q installed successfully.\n", serviceName)
|
|
104
|
+
fmt.Printf("Start it with: %s start\n", filepath.Base(exePath))
|
|
105
|
+
return nil
|
|
106
|
+
})
|
|
101
107
|
}
|
|
102
108
|
|
|
103
109
|
func uninstallService() error {
|
|
104
|
-
m
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
s, err := m.OpenService(serviceName)
|
|
111
|
-
if err != nil {
|
|
112
|
-
return fmt.Errorf("uninstall failed: service %q not found: %w", serviceName, err)
|
|
113
|
-
}
|
|
114
|
-
defer s.Close()
|
|
110
|
+
return withServiceManager(func(m *mgr.Mgr) error {
|
|
111
|
+
s, err := m.OpenService(serviceName)
|
|
112
|
+
if err != nil {
|
|
113
|
+
return fmt.Errorf("uninstall failed: service %q not found: %w", serviceName, err)
|
|
114
|
+
}
|
|
115
|
+
defer s.Close()
|
|
115
116
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
117
|
+
if err := s.Delete(); err != nil {
|
|
118
|
+
return fmt.Errorf("uninstall failed: delete service: %w", err)
|
|
119
|
+
}
|
|
119
120
|
|
|
120
|
-
|
|
121
|
-
|
|
121
|
+
fmt.Printf("Service %q uninstalled.\n", serviceName)
|
|
122
|
+
return nil
|
|
123
|
+
})
|
|
122
124
|
}
|
|
123
125
|
|
|
124
126
|
func startService() error {
|
|
125
|
-
m
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
s, err := m.OpenService(serviceName)
|
|
132
|
-
if err != nil {
|
|
133
|
-
return fmt.Errorf("start failed: service %q not found: %w", serviceName, err)
|
|
134
|
-
}
|
|
135
|
-
defer s.Close()
|
|
127
|
+
return withServiceManager(func(m *mgr.Mgr) error {
|
|
128
|
+
s, err := m.OpenService(serviceName)
|
|
129
|
+
if err != nil {
|
|
130
|
+
return fmt.Errorf("start failed: service %q not found: %w", serviceName, err)
|
|
131
|
+
}
|
|
132
|
+
defer s.Close()
|
|
136
133
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
134
|
+
if err := s.Start(); err != nil {
|
|
135
|
+
return fmt.Errorf("start failed: %w", err)
|
|
136
|
+
}
|
|
140
137
|
|
|
141
|
-
|
|
142
|
-
|
|
138
|
+
fmt.Printf("Service %q started.\n", serviceName)
|
|
139
|
+
return nil
|
|
140
|
+
})
|
|
143
141
|
}
|
|
144
142
|
|
|
145
143
|
func stopService() error {
|
|
146
|
-
m
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
s, err := m.OpenService(serviceName)
|
|
153
|
-
if err != nil {
|
|
154
|
-
return fmt.Errorf("stop failed: service %q not found: %w", serviceName, err)
|
|
155
|
-
}
|
|
156
|
-
defer s.Close()
|
|
144
|
+
return withServiceManager(func(m *mgr.Mgr) error {
|
|
145
|
+
s, err := m.OpenService(serviceName)
|
|
146
|
+
if err != nil {
|
|
147
|
+
return fmt.Errorf("stop failed: service %q not found: %w", serviceName, err)
|
|
148
|
+
}
|
|
149
|
+
defer s.Close()
|
|
157
150
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
151
|
+
if _, err := s.Control(svc.Stop); err != nil {
|
|
152
|
+
return fmt.Errorf("stop failed: %w", err)
|
|
153
|
+
}
|
|
161
154
|
|
|
162
|
-
|
|
163
|
-
|
|
155
|
+
fmt.Printf("Service %q stopping.\n", serviceName)
|
|
156
|
+
return nil
|
|
157
|
+
})
|
|
164
158
|
}
|
|
165
159
|
|
|
166
160
|
func serviceStatus() error {
|
|
167
|
-
m
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
s, err := m.OpenService(serviceName)
|
|
174
|
-
if err != nil {
|
|
175
|
-
return fmt.Errorf("status failed: service %q not found: %w", serviceName, err)
|
|
176
|
-
}
|
|
177
|
-
defer s.Close()
|
|
161
|
+
return withServiceManager(func(m *mgr.Mgr) error {
|
|
162
|
+
s, err := m.OpenService(serviceName)
|
|
163
|
+
if err != nil {
|
|
164
|
+
return fmt.Errorf("status failed: service %q not found: %w", serviceName, err)
|
|
165
|
+
}
|
|
166
|
+
defer s.Close()
|
|
178
167
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
168
|
+
st, err := s.Query()
|
|
169
|
+
if err != nil {
|
|
170
|
+
return fmt.Errorf("status failed: query service: %w", err)
|
|
171
|
+
}
|
|
183
172
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
173
|
+
states := map[svc.State]string{
|
|
174
|
+
svc.Stopped: "Stopped",
|
|
175
|
+
svc.StartPending: "StartPending",
|
|
176
|
+
svc.StopPending: "StopPending",
|
|
177
|
+
svc.Running: "Running",
|
|
178
|
+
svc.ContinuePending: "ContinuePending",
|
|
179
|
+
svc.PausePending: "PausePending",
|
|
180
|
+
svc.Paused: "Paused",
|
|
181
|
+
}
|
|
182
|
+
state, ok := states[st.State]
|
|
183
|
+
if !ok {
|
|
184
|
+
state = fmt.Sprintf("Unknown(%d)", st.State)
|
|
185
|
+
}
|
|
197
186
|
|
|
198
|
-
|
|
199
|
-
|
|
187
|
+
fmt.Printf("Service %q: %s\n", serviceName, state)
|
|
188
|
+
return nil
|
|
189
|
+
})
|
|
200
190
|
}
|
|
201
191
|
|
|
202
192
|
func isWindowsService() (bool, error) {
|
|
203
193
|
return svc.IsWindowsService()
|
|
204
194
|
}
|
|
205
|
-
|
|
206
|
-
func filepathBase(path string) string {
|
|
207
|
-
if path == "" {
|
|
208
|
-
return serviceName
|
|
209
|
-
}
|
|
210
|
-
for i := len(path) - 1; i >= 0; i-- {
|
|
211
|
-
if path[i] == '\\' || path[i] == '/' {
|
|
212
|
-
return path[i+1:]
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
return path
|
|
216
|
-
}
|
package/supervisor/updater.go
CHANGED
|
@@ -433,6 +433,9 @@ func (u *Updater) checkSupervisorUpdate(ctx context.Context) {
|
|
|
433
433
|
u.state.Transition(updateScopeSupervisor, updatePhaseStaged, remote, local, "")
|
|
434
434
|
notifyUpdaterOperator(u.cfg, u.log, fmt.Sprintf("⚙️ Supervisor: downloaded %s. Restarting supervisor to apply update...", remote), 0)
|
|
435
435
|
|
|
436
|
+
// Reset start time so minimum uptime is re-enforced after restart
|
|
437
|
+
u.startAt = time.Now()
|
|
438
|
+
|
|
436
439
|
isService, err := isWindowsService()
|
|
437
440
|
if err != nil {
|
|
438
441
|
markFailed(err)
|
|
@@ -499,14 +502,6 @@ func (u *Updater) downloadSupervisorBinary(ctx context.Context, downloadURL stri
|
|
|
499
502
|
return fmt.Errorf("downloaded empty binary")
|
|
500
503
|
}
|
|
501
504
|
|
|
502
|
-
info, err := os.Stat(tmpPath)
|
|
503
|
-
if err != nil {
|
|
504
|
-
return err
|
|
505
|
-
}
|
|
506
|
-
if info.Size() <= 0 {
|
|
507
|
-
return fmt.Errorf("downloaded binary has invalid size %d", info.Size())
|
|
508
|
-
}
|
|
509
|
-
|
|
510
505
|
if err := os.Remove(u.cfg.Paths.PendingBinary); err != nil && !os.IsNotExist(err) {
|
|
511
506
|
return err
|
|
512
507
|
}
|
|
@@ -514,7 +509,7 @@ func (u *Updater) downloadSupervisorBinary(ctx context.Context, downloadURL stri
|
|
|
514
509
|
return err
|
|
515
510
|
}
|
|
516
511
|
|
|
517
|
-
u.log.Info("Supervisor binary downloaded to %s (%d bytes)", u.cfg.Paths.PendingBinary,
|
|
512
|
+
u.log.Info("Supervisor binary downloaded to %s (%d bytes)", u.cfg.Paths.PendingBinary, written)
|
|
518
513
|
return nil
|
|
519
514
|
}
|
|
520
515
|
|
|
@@ -548,7 +543,7 @@ func requestSupervisorRestart(log *Logger) error {
|
|
|
548
543
|
}
|
|
549
544
|
|
|
550
545
|
go func() {
|
|
551
|
-
time.Sleep(
|
|
546
|
+
time.Sleep(2 * time.Second)
|
|
552
547
|
os.Exit(0)
|
|
553
548
|
}()
|
|
554
549
|
|
|
@@ -557,6 +552,12 @@ func requestSupervisorRestart(log *Logger) error {
|
|
|
557
552
|
|
|
558
553
|
func (u *Updater) killServer() {
|
|
559
554
|
u.log.Info("Updater: stopping current MCP server for update")
|
|
555
|
+
|
|
556
|
+
// Ask the MCP server to write a reconnect snapshot before we kill it.
|
|
557
|
+
// On Windows, taskkill /F doesn't allow graceful shutdown, so this is
|
|
558
|
+
// the only way the snapshot gets written.
|
|
559
|
+
u.mcp.PrepareShutdown(context.Background())
|
|
560
|
+
|
|
560
561
|
pid, err := ReadPIDFile(u.cfg.Paths.ServerPID)
|
|
561
562
|
if err != nil {
|
|
562
563
|
u.log.Warn("Could not read server PID file: %v", err)
|
|
@@ -600,7 +601,7 @@ func (u *Updater) clearNpxCache() {
|
|
|
600
601
|
}
|
|
601
602
|
pkgDir := filepath.Join(base, e.Name(), "node_modules", "sensorium-mcp")
|
|
602
603
|
// Validate path doesn't escape base directory
|
|
603
|
-
if !strings.HasPrefix(pkgDir, base) {
|
|
604
|
+
if !strings.HasPrefix(pkgDir, base+string(os.PathSeparator)) {
|
|
604
605
|
continue
|
|
605
606
|
}
|
|
606
607
|
if _, err := os.Stat(pkgDir); err == nil {
|