sensorium-mcp 3.0.3 → 3.0.5

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.
Files changed (112) hide show
  1. package/dist/dashboard/routes/data.d.ts.map +1 -1
  2. package/dist/dashboard/routes/data.js +2 -1
  3. package/dist/dashboard/routes/data.js.map +1 -1
  4. package/dist/dashboard/routes/threads.js +1 -1
  5. package/dist/dashboard/routes/threads.js.map +1 -1
  6. package/dist/dashboard/routes.d.ts.map +1 -1
  7. package/dist/dashboard/routes.js +1 -3
  8. package/dist/dashboard/routes.js.map +1 -1
  9. package/dist/data/memory/migration-runner.d.ts +1 -1
  10. package/dist/data/memory/migration-runner.d.ts.map +1 -1
  11. package/dist/data/memory/migration-runner.js +59 -3
  12. package/dist/data/memory/migration-runner.js.map +1 -1
  13. package/dist/data/memory/schema-ddl.d.ts +1 -1
  14. package/dist/data/memory/schema-ddl.d.ts.map +1 -1
  15. package/dist/data/memory/schema-ddl.js +2 -1
  16. package/dist/data/memory/schema-ddl.js.map +1 -1
  17. package/dist/data/memory/thread-registry.js +1 -1
  18. package/dist/data/memory/thread-registry.js.map +1 -1
  19. package/dist/http-server.d.ts.map +1 -1
  20. package/dist/http-server.js +1 -9
  21. package/dist/http-server.js.map +1 -1
  22. package/dist/index.js +3 -6
  23. package/dist/index.js.map +1 -1
  24. package/dist/server/factory.js +1 -1
  25. package/dist/server/factory.js.map +1 -1
  26. package/dist/services/agent-spawn.service.d.ts +7 -1
  27. package/dist/services/agent-spawn.service.d.ts.map +1 -1
  28. package/dist/services/agent-spawn.service.js +69 -45
  29. package/dist/services/agent-spawn.service.js.map +1 -1
  30. package/dist/services/consolidation.service.d.ts.map +1 -1
  31. package/dist/services/consolidation.service.js +49 -35
  32. package/dist/services/consolidation.service.js.map +1 -1
  33. package/dist/services/keeper.service.d.ts +21 -0
  34. package/dist/services/keeper.service.d.ts.map +1 -0
  35. package/dist/services/keeper.service.js +195 -0
  36. package/dist/services/keeper.service.js.map +1 -0
  37. package/dist/services/maintenance-signal.d.ts +2 -0
  38. package/dist/services/maintenance-signal.d.ts.map +1 -1
  39. package/dist/services/maintenance-signal.js +7 -1
  40. package/dist/services/maintenance-signal.js.map +1 -1
  41. package/dist/services/process.service.d.ts +19 -2
  42. package/dist/services/process.service.d.ts.map +1 -1
  43. package/dist/services/process.service.js +104 -10
  44. package/dist/services/process.service.js.map +1 -1
  45. package/dist/services/reconnect-snapshot.service.d.ts.map +1 -1
  46. package/dist/services/reconnect-snapshot.service.js +20 -3
  47. package/dist/services/reconnect-snapshot.service.js.map +1 -1
  48. package/dist/services/thread-lifecycle.service.d.ts +5 -0
  49. package/dist/services/thread-lifecycle.service.d.ts.map +1 -1
  50. package/dist/services/thread-lifecycle.service.js +33 -8
  51. package/dist/services/thread-lifecycle.service.js.map +1 -1
  52. package/dist/services/worker-cleanup.service.d.ts +14 -1
  53. package/dist/services/worker-cleanup.service.d.ts.map +1 -1
  54. package/dist/services/worker-cleanup.service.js +48 -27
  55. package/dist/services/worker-cleanup.service.js.map +1 -1
  56. package/dist/sessions.d.ts +0 -5
  57. package/dist/sessions.d.ts.map +1 -1
  58. package/dist/sessions.js +0 -7
  59. package/dist/sessions.js.map +1 -1
  60. package/dist/stdio-server.d.ts.map +1 -1
  61. package/dist/stdio-server.js +1 -7
  62. package/dist/stdio-server.js.map +1 -1
  63. package/dist/tools/delegate-tool.d.ts.map +1 -1
  64. package/dist/tools/delegate-tool.js +2 -2
  65. package/dist/tools/delegate-tool.js.map +1 -1
  66. package/dist/tools/session-tools.js +1 -1
  67. package/dist/tools/session-tools.js.map +1 -1
  68. package/dist/tools/start-session-tool.d.ts.map +1 -1
  69. package/dist/tools/start-session-tool.js +8 -9
  70. package/dist/tools/start-session-tool.js.map +1 -1
  71. package/dist/tools/wait/message-processing.d.ts.map +1 -1
  72. package/dist/tools/wait/message-processing.js +28 -0
  73. package/dist/tools/wait/message-processing.js.map +1 -1
  74. package/dist/tools/wait/poll-loop.js +1 -1
  75. package/dist/tools/wait/poll-loop.js.map +1 -1
  76. package/package.json +1 -1
  77. package/dist/tools/thread-lifecycle.d.ts +0 -6
  78. package/dist/tools/thread-lifecycle.d.ts.map +0 -1
  79. package/dist/tools/thread-lifecycle.js +0 -6
  80. package/dist/tools/thread-lifecycle.js.map +0 -1
  81. package/supervisor/config.go +0 -253
  82. package/supervisor/config_test.go +0 -78
  83. package/supervisor/go.mod +0 -15
  84. package/supervisor/go.sum +0 -20
  85. package/supervisor/health.go +0 -433
  86. package/supervisor/health_test.go +0 -93
  87. package/supervisor/keeper.go +0 -309
  88. package/supervisor/keeper_test.go +0 -27
  89. package/supervisor/lock.go +0 -57
  90. package/supervisor/lock_test.go +0 -54
  91. package/supervisor/log.go +0 -195
  92. package/supervisor/log_test.go +0 -125
  93. package/supervisor/main.go +0 -461
  94. package/supervisor/main_test.go +0 -130
  95. package/supervisor/notify.go +0 -53
  96. package/supervisor/process.go +0 -294
  97. package/supervisor/process_test.go +0 -108
  98. package/supervisor/process_unix.go +0 -14
  99. package/supervisor/process_windows.go +0 -15
  100. package/supervisor/secrets.go +0 -95
  101. package/supervisor/secrets_securevault_test.go +0 -98
  102. package/supervisor/secrets_test.go +0 -119
  103. package/supervisor/self_update.go +0 -282
  104. package/supervisor/self_update_test.go +0 -177
  105. package/supervisor/service_restart_stub.go +0 -9
  106. package/supervisor/service_restart_windows.go +0 -63
  107. package/supervisor/service_stub.go +0 -15
  108. package/supervisor/service_windows.go +0 -194
  109. package/supervisor/update_state.go +0 -264
  110. package/supervisor/update_state_test.go +0 -306
  111. package/supervisor/updater.go +0 -613
  112. package/supervisor/updater_test.go +0 -64
@@ -1,125 +0,0 @@
1
- package main
2
-
3
- import (
4
- "os"
5
- "path/filepath"
6
- "strings"
7
- "testing"
8
- )
9
-
10
- func TestLogRotation(t *testing.T) {
11
- dir := t.TempDir()
12
- logPath := filepath.Join(dir, "test.log")
13
-
14
- l := &Logger{
15
- logPath: logPath,
16
- maxSize: 100, // tiny threshold for test
17
- maxKeep: 2,
18
- }
19
- l.openFile()
20
- defer l.Close()
21
-
22
- // Write enough lines to trigger multiple rotations
23
- for i := 0; i < 50; i++ {
24
- l.Info("line %d: %s", i, strings.Repeat("x", 20))
25
- }
26
-
27
- // Current log should exist and be under maxSize
28
- info, err := os.Stat(logPath)
29
- if err != nil {
30
- t.Fatalf("log file missing: %v", err)
31
- }
32
- if info.Size() >= 100 {
33
- t.Errorf("log file should have been rotated, size=%d", info.Size())
34
- }
35
-
36
- // At least one rotated file should exist (timestamp-based, e.g. test.2026-04-15T....log)
37
- entries, err := os.ReadDir(dir)
38
- if err != nil {
39
- t.Fatalf("cannot read dir: %v", err)
40
- }
41
- var rotated []string
42
- for _, e := range entries {
43
- if e.Name() != "test.log" && strings.HasPrefix(e.Name(), "test.") {
44
- rotated = append(rotated, e.Name())
45
- }
46
- }
47
- if len(rotated) == 0 {
48
- t.Error("expected at least one rotated file to exist")
49
- }
50
-
51
- // maxKeep=2: no more than 2 rotated files should exist
52
- if len(rotated) > 2 {
53
- t.Errorf("expected at most 2 rotated files (maxKeep=2), got %d: %v", len(rotated), rotated)
54
- }
55
- }
56
-
57
- func TestDailyRotation(t *testing.T) {
58
- dir := t.TempDir()
59
- logPath := filepath.Join(dir, "test.log")
60
-
61
- // Write a fake "yesterday" log
62
- yesterday := "2026-04-14"
63
- if err := os.WriteFile(logPath, []byte("old log content\n"), 0644); err != nil {
64
- t.Fatal(err)
65
- }
66
-
67
- l := &Logger{
68
- logPath: logPath,
69
- maxSize: 5 * 1024 * 1024,
70
- maxKeep: 7,
71
- today: "2026-04-15", // simulate tomorrow
72
- }
73
- l.rotateDailyIfNeeded()
74
-
75
- // Original file should have been renamed to test.2026-04-14.log (mod date matches)
76
- // (mod date may be today in tests, so just verify the original is gone or renamed)
77
- entries, err := os.ReadDir(dir)
78
- if err != nil {
79
- t.Fatal(err)
80
- }
81
- var found bool
82
- for _, e := range entries {
83
- if strings.Contains(e.Name(), yesterday) || (e.Name() != "test.log" && strings.HasPrefix(e.Name(), "test.")) {
84
- found = true
85
- }
86
- }
87
- _ = found // rotation may or may not fire depending on file mod time in test env
88
- }
89
-
90
- func TestLogRotationMaxKeep(t *testing.T) {
91
- dir := t.TempDir()
92
- logPath := filepath.Join(dir, "test.log")
93
-
94
- // Pre-create 5 fake rotated files
95
- for i := 0; i < 5; i++ {
96
- fake := filepath.Join(dir, "test.2026-04-1"+string(rune('0'+i))+"T120000.log")
97
- if err := os.WriteFile(fake, []byte("x"), 0644); err != nil {
98
- t.Fatal(err)
99
- }
100
- }
101
-
102
- l := &Logger{
103
- logPath: logPath,
104
- maxSize: 100,
105
- maxKeep: 2,
106
- }
107
- l.openFile()
108
- defer l.Close()
109
-
110
- l.pruneOldLogs()
111
-
112
- entries, err := os.ReadDir(dir)
113
- if err != nil {
114
- t.Fatal(err)
115
- }
116
- var rotated []string
117
- for _, e := range entries {
118
- if e.Name() != "test.log" && strings.HasPrefix(e.Name(), "test.") {
119
- rotated = append(rotated, e.Name())
120
- }
121
- }
122
- if len(rotated) > 2 {
123
- t.Errorf("pruneOldLogs should have left at most maxKeep=2, got %d: %v", len(rotated), rotated)
124
- }
125
- }
@@ -1,461 +0,0 @@
1
- package main
2
-
3
- import (
4
- "context"
5
- "flag"
6
- "fmt"
7
- "os"
8
- "os/signal"
9
- "strings"
10
- "sync"
11
- "syscall"
12
- "time"
13
- )
14
-
15
- var (
16
- globalCancelMu sync.Mutex
17
- globalCancel context.CancelFunc
18
- )
19
-
20
- // KeeperEntry tracks a running keeper and its settings.
21
- type KeeperEntry struct {
22
- keeper *Keeper
23
- settings KeeperConfig
24
- }
25
-
26
- func main() {
27
- isService, err := isWindowsService()
28
- if err != nil {
29
- fmt.Fprintf(os.Stderr, "Failed to detect service mode: %v\n", err)
30
- os.Exit(1)
31
- }
32
- if isService {
33
- if err := runAsService(); err != nil {
34
- fmt.Fprintf(os.Stderr, "Service run failed: %v\n", err)
35
- os.Exit(1)
36
- }
37
- return
38
- }
39
-
40
- if handled, err := handleServiceCommand(os.Args[1:]); err != nil {
41
- fmt.Fprintf(os.Stderr, "%v\n", err)
42
- os.Exit(1)
43
- } else if handled {
44
- return
45
- }
46
-
47
- runningAsService := resolveRunSupervisorMode(isService, os.Getenv("HOST_MODE"))
48
- if err := runSupervisor(runningAsService); err != nil {
49
- fmt.Fprintf(os.Stderr, "Supervisor failed: %v\n", err)
50
- os.Exit(1)
51
- }
52
- }
53
-
54
- func resolveRunSupervisorMode(processIsService bool, hostModeValue string) bool {
55
- if processIsService {
56
- return true
57
- }
58
-
59
- return parseHostMode(hostModeValue, false) == "service"
60
- }
61
-
62
- func handleServiceCommand(args []string) (bool, error) {
63
- if len(args) == 0 {
64
- return false, nil
65
- }
66
-
67
- switch args[0] {
68
- case "install":
69
- fs := flag.NewFlagSet("install", flag.ContinueOnError)
70
- serviceUser := fs.String("service-user", "", "Windows account to run service as (e.g. .\\YourUser). Defaults to LocalSystem if empty.")
71
- servicePassword := fs.String("service-password", "", "Password for the service account (required for regular user accounts; not needed for LocalSystem/LocalService/NetworkService, NT SERVICE\\*, or gMSA names ending with '$').")
72
- if err := fs.Parse(args[1:]); err != nil {
73
- return true, err
74
- }
75
- if *serviceUser != "" && *servicePassword == "" && !isPasswordlessServiceIdentity(*serviceUser) {
76
- return true, fmt.Errorf("install failed: -service-password is required for regular -service-user accounts\nAllowed passwordless identities: LocalSystem/LocalService/NetworkService, NT SERVICE\\*, and gMSA names ending with '$'\nNote: prefer using Install-Sensorium.ps1, which prompts securely for passwords")
77
- }
78
- exePath, err := os.Executable()
79
- if err != nil {
80
- return true, fmt.Errorf("install failed: resolve executable: %w", err)
81
- }
82
- return true, installService(exePath, *serviceUser, *servicePassword)
83
- case "uninstall":
84
- return true, uninstallService()
85
- case "start":
86
- return true, startService()
87
- case "stop":
88
- return true, stopService()
89
- case "status":
90
- return true, serviceStatus()
91
- default:
92
- return false, nil
93
- }
94
- }
95
-
96
- func isPasswordlessServiceIdentity(user string) bool {
97
- trimmed := strings.TrimSpace(user)
98
- if trimmed == "" {
99
- return false
100
- }
101
-
102
- lower := strings.ToLower(trimmed)
103
- switch lower {
104
- case "localsystem", "nt authority\\system", "localservice", "nt authority\\localservice", "networkservice", "nt authority\\networkservice":
105
- return true
106
- }
107
-
108
- if strings.HasPrefix(lower, "nt service\\") {
109
- return true
110
- }
111
-
112
- return strings.HasSuffix(trimmed, "$")
113
- }
114
-
115
- func stopSupervisor() {
116
- globalCancelMu.Lock()
117
- fn := globalCancel
118
- globalCancelMu.Unlock()
119
- if fn != nil {
120
- fn()
121
- }
122
- }
123
-
124
- func runSupervisor(runningAsService bool) error {
125
- cfg := LoadConfig(runningAsService)
126
-
127
- if err := os.MkdirAll(cfg.DataDir, 0755); err != nil {
128
- return fmt.Errorf("cannot create data dir %s: %w", cfg.DataDir, err)
129
- }
130
-
131
- log := NewLogger(cfg.Paths.WatcherLog)
132
- defer log.Close()
133
-
134
- // Acquire lock — prevent multiple instances
135
- if !AcquireLock(cfg.Paths.WatcherLock, log) {
136
- return fmt.Errorf("another supervisor instance is already running")
137
- }
138
- defer ReleaseLock(cfg.Paths.WatcherLock)
139
-
140
- shouldRestart, err := applyPendingSupervisorUpdate(cfg, log)
141
- if err != nil {
142
- log.Warn("Pending supervisor update could not be applied: %v", err)
143
- }
144
- if shouldRestart {
145
- return nil
146
- }
147
-
148
- recoverPersistedUpdateStateOnStartup(cfg, log)
149
-
150
- log.Info("sensorium-supervisor starting (mode=%s, hostMode=%s, port=%d, dataDir=%s)", cfg.Mode, cfg.HostMode, cfg.MCPHttpPort, cfg.DataDir)
151
- log.Debug("Config: MCPStartCommand=%q, PollInterval=%v, MinUptime=%v, KeeperMaxRetries=%d", cfg.MCPStartCommand, cfg.PollInterval, cfg.MinUptime, cfg.KeeperMaxRetries)
152
- log.Debug("Config: TelegramToken=%v, HealthFailThresh=%d, StuckThreshold=%v", cfg.TelegramToken != "", cfg.HealthFailThresh, cfg.StuckThreshold)
153
-
154
- if err := os.MkdirAll(cfg.Paths.PIDsDir, 0755); err != nil {
155
- log.Warn("Cannot create PIDs dir %s: %v", cfg.Paths.PIDsDir, err)
156
- }
157
- if err := os.MkdirAll(cfg.Paths.HeartbeatsDir, 0755); err != nil {
158
- log.Warn("Cannot create heartbeats dir %s: %v", cfg.Paths.HeartbeatsDir, err)
159
- }
160
-
161
- if cfg.MCPHttpPort <= 0 {
162
- log.Error("MCP_HTTP_PORT must be set (got %d)", cfg.MCPHttpPort)
163
- return fmt.Errorf("MCP_HTTP_PORT must be set (got %d)", cfg.MCPHttpPort)
164
- }
165
-
166
- mcp := NewMCPClient(cfg.MCPHttpPort, cfg.MCPHttpSecret)
167
- mcp.Log = log
168
-
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)
179
- KillByPort(cfg.MCPHttpPort, log)
180
-
181
- // Spawn MCP server
182
- _, err = SpawnMCPServer(cfg, log)
183
- if err != nil {
184
- log.Error("Failed to start MCP server: %v", err)
185
- return fmt.Errorf("failed to start MCP server: %w", err)
186
- }
187
-
188
- // Wait for server to be ready
189
- ctx, rootCancel := context.WithCancel(context.Background())
190
- defer rootCancel()
191
- globalCancelMu.Lock()
192
- globalCancel = rootCancel
193
- globalCancelMu.Unlock()
194
- defer func() {
195
- globalCancelMu.Lock()
196
- globalCancel = nil
197
- globalCancelMu.Unlock()
198
- }()
199
-
200
- if mcp.WaitForReady(ctx, 3*time.Second, cfg.KeeperReadyTimeout) {
201
- log.Info("MCP server is ready")
202
- } else {
203
- log.Warn("MCP server did not become ready in %v — proceeding anyway", cfg.KeeperReadyTimeout)
204
- }
205
-
206
- // Start keeper management
207
- var mu sync.Mutex
208
- keepers := make(map[int]*KeeperEntry)
209
-
210
- onDeath := func(threadID int, sessionName string) {
211
- log.Warn("Thread %d ('%s') died", threadID, sessionName)
212
- NotifyOperator(cfg, log, fmt.Sprintf("💀 <b>%s</b> session died — restarting…", sessionName), threadID)
213
- }
214
-
215
- syncKeepers := func() {
216
- if cfg.MCPHttpPort <= 0 {
217
- log.Debug("syncKeepers: skipped (no port configured)")
218
- return
219
- }
220
-
221
- log.Debug("syncKeepers: fetching keeper settings...")
222
- settings, err := fetchKeeperSettings(ctx, mcp, log)
223
- if err != nil {
224
- log.Warn("Failed to fetch keeper settings: %v", err)
225
- return
226
- }
227
- log.Debug("syncKeepers: got %d keeper configs", len(settings))
228
-
229
- mu.Lock()
230
- defer mu.Unlock()
231
-
232
- // Find keepers to remove (no longer in settings)
233
- wanted := make(map[int]bool)
234
- for _, s := range settings {
235
- wanted[s.ThreadID] = true
236
- }
237
- for tid, entry := range keepers {
238
- if !wanted[tid] {
239
- log.Info("Stopping keeper for removed thread %d", tid)
240
- entry.keeper.Stop()
241
- delete(keepers, tid)
242
- }
243
- }
244
-
245
- // Start or update keepers
246
- for _, s := range settings {
247
- existing, exists := keepers[s.ThreadID]
248
- if exists && settingsChanged(existing.settings, s) {
249
- log.Info("Settings changed for thread %d — restarting keeper", s.ThreadID)
250
- existing.keeper.Stop()
251
- delete(keepers, s.ThreadID)
252
- exists = false
253
- }
254
- if !exists {
255
- k := NewKeeper(s, cfg, mcp, log, onDeath)
256
- k.Start()
257
- keepers[s.ThreadID] = &KeeperEntry{keeper: k, settings: s}
258
- log.Info("Started keeper for thread %d ('%s')", s.ThreadID, s.SessionName)
259
- }
260
- }
261
- }
262
-
263
- // Initial sync
264
- log.Info("Running initial keeper sync")
265
- syncKeepers()
266
-
267
- // Keeper settings poller (every 2 min)
268
- keeperPollerDone := make(chan struct{})
269
- go func() {
270
- defer close(keeperPollerDone)
271
- ticker := time.NewTicker(2 * time.Minute)
272
- defer ticker.Stop()
273
- for {
274
- select {
275
- case <-ctx.Done():
276
- return
277
- case <-ticker.C:
278
- log.Debug("Keeper settings poll triggered")
279
- syncKeepers()
280
- }
281
- }
282
- }()
283
-
284
- // Start updater
285
- log.Info("Starting auto-updater")
286
- updater := NewUpdater(cfg, mcp, log)
287
- updater.Start()
288
-
289
- // Health check loop for the server process itself
290
- healthDone := make(chan struct{})
291
- go func() {
292
- defer close(healthDone)
293
- consecutiveFails := 0
294
- ticker := time.NewTicker(60 * time.Second)
295
- defer ticker.Stop()
296
- for {
297
- select {
298
- case <-ctx.Done():
299
- return
300
- case <-ticker.C:
301
- if mcp.IsServerReady(ctx) {
302
- if consecutiveFails > 0 {
303
- log.Info("Server health check recovered (was at %d fails)", consecutiveFails)
304
- }
305
- consecutiveFails = 0
306
- } else {
307
- consecutiveFails++
308
- log.Warn("Server health check failed (%d/%d)", consecutiveFails, cfg.HealthFailThresh)
309
- if consecutiveFails >= cfg.HealthFailThresh {
310
- log.Error("Server unresponsive after %d consecutive failures — restarting", consecutiveFails)
311
- NotifyOperator(cfg, log, "⚠️ Supervisor: server process not running — restarting...", 0)
312
-
313
- // Kill and respawn
314
- pid, pidErr := ReadPIDFile(cfg.Paths.ServerPID)
315
- if pidErr != nil {
316
- log.Warn("Could not read server PID file: %v", pidErr)
317
- }
318
- if pid > 0 {
319
- _ = KillProcess(pid, log)
320
- }
321
- KillByPort(cfg.MCPHttpPort, log)
322
-
323
- if _, err := SpawnMCPServer(cfg, log); err != nil {
324
- log.Error("Failed to respawn server: %v", err)
325
- }
326
- consecutiveFails = 0
327
- }
328
- }
329
- }
330
- }
331
- }()
332
-
333
- // Wait for shutdown signal
334
- sigCh := make(chan os.Signal, 1)
335
- signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
336
-
337
- log.Info("All subsystems started — supervisor is running (PID %d)", os.Getpid())
338
-
339
- select {
340
- case sig := <-sigCh:
341
- log.Info("Received %s — shutting down", sig)
342
- rootCancel()
343
- case <-ctx.Done():
344
- log.Info("Shutdown requested")
345
- }
346
-
347
- // Stop keepers (with 10s timeout)
348
- shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
349
- defer shutdownCancel()
350
-
351
- mu.Lock()
352
- var wg sync.WaitGroup
353
- for _, entry := range keepers {
354
- wg.Add(1)
355
- go func(k *Keeper) {
356
- defer wg.Done()
357
- k.Stop()
358
- }(entry.keeper)
359
- }
360
- mu.Unlock()
361
-
362
- doneCh := make(chan struct{})
363
- go func() { wg.Wait(); close(doneCh) }()
364
- select {
365
- case <-doneCh:
366
- log.Info("All keepers stopped")
367
- case <-shutdownCtx.Done():
368
- log.Warn("Keeper shutdown timed out after 10s")
369
- }
370
-
371
- // Stop updater
372
- updater.Stop()
373
-
374
- // Wait for background goroutines
375
- <-keeperPollerDone
376
- <-healthDone
377
-
378
- // Ask MCP to write reconnect snapshot before killing it
379
- mcp.PrepareShutdown(context.Background())
380
-
381
- // Kill server process
382
- pid, err := ReadPIDFile(cfg.Paths.ServerPID)
383
- if err == nil && pid > 0 {
384
- log.Info("Stopping MCP server (PID %d)", pid)
385
- _ = KillProcess(pid, log)
386
- }
387
-
388
- log.Info("Supervisor stopped cleanly")
389
- return nil
390
- }
391
-
392
- // fetchKeeperSettings reads all keepAlive threads from the MCP server
393
- // (root, branch, and daily — excludes worker threads).
394
- func fetchKeeperSettings(ctx context.Context, mcp *MCPClient, log *Logger) ([]KeeperConfig, error) {
395
- roots, err := mcp.GetKeepAliveThreads(ctx)
396
- if err != nil {
397
- return nil, err
398
- }
399
-
400
- var result []KeeperConfig
401
- for _, r := range roots {
402
- keepAlive, _ := r["keepAlive"].(bool)
403
- if !keepAlive {
404
- continue
405
- }
406
-
407
- // Skip non-active roots (archived, expired, exited)
408
- if status, _ := r["status"].(string); status != "" && status != "active" {
409
- continue
410
- }
411
-
412
- tidFloat, _ := r["threadId"].(float64) // JSON numbers decode as float64
413
- tid := int(tidFloat)
414
- if tid <= 0 {
415
- continue
416
- }
417
-
418
- client := "claude"
419
- if c, ok := r["client"].(string); ok && c != "" {
420
- client = c
421
- }
422
-
423
- sessionName := ""
424
- if n, ok := r["name"].(string); ok {
425
- sessionName = n
426
- }
427
-
428
- maxRetries := 5
429
- if mr, ok := r["maxRetries"].(float64); ok {
430
- maxRetries = int(mr)
431
- }
432
-
433
- cooldownMs := 300_000
434
- if cd, ok := r["cooldownMs"].(float64); ok {
435
- cooldownMs = int(cd)
436
- }
437
-
438
- workDir := ""
439
- if wd, ok := r["workingDirectory"].(string); ok {
440
- workDir = wd
441
- }
442
-
443
- result = append(result, KeeperConfig{
444
- ThreadID: tid,
445
- SessionName: sessionName,
446
- Client: client,
447
- WorkingDirectory: workDir,
448
- MaxRetries: maxRetries,
449
- CooldownMs: cooldownMs,
450
- })
451
- }
452
- return result, nil
453
- }
454
-
455
- func settingsChanged(a, b KeeperConfig) bool {
456
- return a.MaxRetries != b.MaxRetries ||
457
- a.CooldownMs != b.CooldownMs ||
458
- a.Client != b.Client ||
459
- a.SessionName != b.SessionName ||
460
- a.WorkingDirectory != b.WorkingDirectory
461
- }
@@ -1,130 +0,0 @@
1
- package main
2
-
3
- import (
4
- "os"
5
- "path/filepath"
6
- "strings"
7
- "testing"
8
- )
9
-
10
- func TestResolveRunSupervisorMode(t *testing.T) {
11
- tests := []struct {
12
- name string
13
- processIsService bool
14
- hostMode string
15
- want bool
16
- }{
17
- {
18
- name: "service process always forces service mode",
19
- processIsService: true,
20
- hostMode: "task",
21
- want: true,
22
- },
23
- {
24
- name: "non-service defaults to task mode",
25
- processIsService: false,
26
- hostMode: "",
27
- want: false,
28
- },
29
- {
30
- name: "non-service task remains task mode",
31
- processIsService: false,
32
- hostMode: "task",
33
- want: false,
34
- },
35
- {
36
- name: "non-service service mode is honored",
37
- processIsService: false,
38
- hostMode: "service",
39
- want: true,
40
- },
41
- }
42
-
43
- for _, tc := range tests {
44
- t.Run(tc.name, func(t *testing.T) {
45
- got := resolveRunSupervisorMode(tc.processIsService, tc.hostMode)
46
- if got != tc.want {
47
- t.Fatalf("resolveRunSupervisorMode(processIsService=%v, hostMode=%q) = %v, want %v", tc.processIsService, tc.hostMode, got, tc.want)
48
- }
49
- })
50
- }
51
- }
52
-
53
- func TestRunSupervisor_DoesNotRecoverPersistedStateWhenWatcherLockNotAcquired(t *testing.T) {
54
- tempHome := t.TempDir()
55
- t.Setenv("USERPROFILE", tempHome)
56
- t.Setenv("HOME", tempHome)
57
- t.Setenv("MCP_HTTP_PORT", "7777")
58
- t.Setenv("MCP_HTTP_SECRET", "test-secret")
59
- t.Setenv("TELEGRAM_TOKEN", "test-token")
60
- t.Setenv("TELEGRAM_CHAT_ID", "test-chat")
61
-
62
- dataDir := filepath.Join(tempHome, ".remote-copilot-mcp")
63
- log := NewLogger(filepath.Join(dataDir, "test.log"))
64
- defer log.Close()
65
-
66
- store := NewUpdateStateStore(filepath.Join(dataDir, "update-state.json"), log)
67
- store.Transition(updateScopeMCP, updatePhaseApplying, "2.0.0", "1.0.0", "")
68
-
69
- watcherLock := filepath.Join(dataDir, "watcher.lock")
70
- if !AcquireLock(watcherLock, log) {
71
- t.Fatal("failed to pre-acquire watcher lock")
72
- }
73
- defer ReleaseLock(watcherLock)
74
-
75
- err := runSupervisor(false)
76
- if err == nil {
77
- t.Fatal("expected runSupervisor to fail when watcher lock is already held")
78
- }
79
- if !strings.Contains(err.Error(), "another supervisor instance") {
80
- t.Fatalf("unexpected error: %v", err)
81
- }
82
-
83
- state, loadErr := store.Load()
84
- if loadErr != nil {
85
- t.Fatalf("failed to load update state: %v", loadErr)
86
- }
87
- if state.Phase != updatePhaseApplying {
88
- t.Fatalf("phase = %q, want %q", state.Phase, updatePhaseApplying)
89
- }
90
- }
91
-
92
- func TestRunSupervisor_DoesNotApplyPendingSupervisorUpdateWhenWatcherLockNotAcquired(t *testing.T) {
93
- tempHome := t.TempDir()
94
- t.Setenv("USERPROFILE", tempHome)
95
- t.Setenv("HOME", tempHome)
96
- t.Setenv("MCP_HTTP_PORT", "7777")
97
- t.Setenv("MCP_HTTP_SECRET", "test-secret")
98
- t.Setenv("TELEGRAM_TOKEN", "test-token")
99
- t.Setenv("TELEGRAM_CHAT_ID", "test-chat")
100
-
101
- dataDir := filepath.Join(tempHome, ".remote-copilot-mcp")
102
- log := NewLogger(filepath.Join(dataDir, "test.log"))
103
- defer log.Close()
104
-
105
- pendingVersion := filepath.Join(dataDir, "bin", "sensorium-supervisor.new.exe.version")
106
- if err := os.MkdirAll(filepath.Dir(pendingVersion), 0755); err != nil {
107
- t.Fatalf("failed to create pending version directory: %v", err)
108
- }
109
- if err := os.WriteFile(pendingVersion, []byte("2.0.0"), 0644); err != nil {
110
- t.Fatalf("failed to create stale pending version file: %v", err)
111
- }
112
-
113
- watcherLock := filepath.Join(dataDir, "watcher.lock")
114
- if !AcquireLock(watcherLock, log) {
115
- t.Fatal("failed to pre-acquire watcher lock")
116
- }
117
- defer ReleaseLock(watcherLock)
118
-
119
- err := runSupervisor(false)
120
- if err == nil {
121
- t.Fatal("expected runSupervisor to fail when watcher lock is already held")
122
- }
123
- if !strings.Contains(err.Error(), "another supervisor instance") {
124
- t.Fatalf("unexpected error: %v", err)
125
- }
126
-
127
- if _, statErr := os.Stat(pendingVersion); statErr != nil {
128
- t.Fatalf("pending supervisor version file was unexpectedly modified: %v", statErr)
129
- }
130
- }