sensorium-mcp 2.17.27 → 3.0.0

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 (224) hide show
  1. package/Install-Sensorium.ps1 +327 -0
  2. package/README.md +14 -0
  3. package/dist/config.d.ts +16 -1
  4. package/dist/config.d.ts.map +1 -1
  5. package/dist/config.js +39 -2
  6. package/dist/config.js.map +1 -1
  7. package/dist/daily-session.d.ts +2 -1
  8. package/dist/daily-session.d.ts.map +1 -1
  9. package/dist/daily-session.js +23 -26
  10. package/dist/daily-session.js.map +1 -1
  11. package/dist/dashboard/routes/settings.d.ts +4 -0
  12. package/dist/dashboard/routes/settings.d.ts.map +1 -1
  13. package/dist/dashboard/routes/settings.js +57 -1
  14. package/dist/dashboard/routes/settings.js.map +1 -1
  15. package/dist/dashboard/routes/threads.d.ts +1 -0
  16. package/dist/dashboard/routes/threads.d.ts.map +1 -1
  17. package/dist/dashboard/routes/threads.js +23 -27
  18. package/dist/dashboard/routes/threads.js.map +1 -1
  19. package/dist/dashboard/routes.d.ts.map +1 -1
  20. package/dist/dashboard/routes.js +7 -2
  21. package/dist/dashboard/routes.js.map +1 -1
  22. package/dist/dashboard/spa.html +11 -11
  23. package/dist/data/interfaces.d.ts +36 -0
  24. package/dist/data/interfaces.d.ts.map +1 -0
  25. package/dist/data/interfaces.js +2 -0
  26. package/dist/data/interfaces.js.map +1 -0
  27. package/dist/data/memory/bootstrap.d.ts +36 -16
  28. package/dist/data/memory/bootstrap.d.ts.map +1 -1
  29. package/dist/data/memory/bootstrap.js +71 -217
  30. package/dist/data/memory/bootstrap.js.map +1 -1
  31. package/dist/data/memory/consolidation.d.ts +35 -34
  32. package/dist/data/memory/consolidation.d.ts.map +1 -1
  33. package/dist/data/memory/consolidation.js +43 -555
  34. package/dist/data/memory/consolidation.js.map +1 -1
  35. package/dist/data/memory/index.d.ts +0 -1
  36. package/dist/data/memory/index.d.ts.map +1 -1
  37. package/dist/data/memory/index.js +0 -1
  38. package/dist/data/memory/index.js.map +1 -1
  39. package/dist/data/memory/migration-runner.d.ts +5 -0
  40. package/dist/data/memory/migration-runner.d.ts.map +1 -0
  41. package/dist/data/memory/migration-runner.js +403 -0
  42. package/dist/data/memory/migration-runner.js.map +1 -0
  43. package/dist/data/memory/reflection.js +1 -1
  44. package/dist/data/memory/schema-ddl.d.ts +4 -0
  45. package/dist/data/memory/schema-ddl.d.ts.map +1 -0
  46. package/dist/data/memory/schema-ddl.js +194 -0
  47. package/dist/data/memory/schema-ddl.js.map +1 -0
  48. package/dist/data/memory/schema-guard.d.ts +3 -0
  49. package/dist/data/memory/schema-guard.d.ts.map +1 -0
  50. package/dist/data/memory/schema-guard.js +184 -0
  51. package/dist/data/memory/schema-guard.js.map +1 -0
  52. package/dist/data/memory/schema.d.ts +2 -5
  53. package/dist/data/memory/schema.d.ts.map +1 -1
  54. package/dist/data/memory/schema.js +6 -834
  55. package/dist/data/memory/schema.js.map +1 -1
  56. package/dist/data/memory/semantic.d.ts +0 -1
  57. package/dist/data/memory/semantic.d.ts.map +1 -1
  58. package/dist/data/memory/semantic.js +2 -8
  59. package/dist/data/memory/semantic.js.map +1 -1
  60. package/dist/data/memory/synthesis.js +2 -2
  61. package/dist/data/memory/synthesis.js.map +1 -1
  62. package/dist/data/memory/thread-registry.d.ts +18 -4
  63. package/dist/data/memory/thread-registry.d.ts.map +1 -1
  64. package/dist/data/memory/thread-registry.js +25 -0
  65. package/dist/data/memory/thread-registry.js.map +1 -1
  66. package/dist/data/sent-message.repository.d.ts +12 -0
  67. package/dist/data/sent-message.repository.d.ts.map +1 -0
  68. package/dist/data/sent-message.repository.js +31 -0
  69. package/dist/data/sent-message.repository.js.map +1 -0
  70. package/dist/http-server.d.ts.map +1 -1
  71. package/dist/http-server.js +23 -2
  72. package/dist/http-server.js.map +1 -1
  73. package/dist/index.js +27 -48
  74. package/dist/index.js.map +1 -1
  75. package/dist/logger.d.ts +7 -2
  76. package/dist/logger.d.ts.map +1 -1
  77. package/dist/logger.js +89 -12
  78. package/dist/logger.js.map +1 -1
  79. package/dist/scheduler.d.ts +8 -0
  80. package/dist/scheduler.d.ts.map +1 -1
  81. package/dist/scheduler.js +15 -0
  82. package/dist/scheduler.js.map +1 -1
  83. package/dist/server/factory.d.ts +2 -1
  84. package/dist/server/factory.d.ts.map +1 -1
  85. package/dist/server/factory.js +11 -4
  86. package/dist/server/factory.js.map +1 -1
  87. package/dist/services/agent-spawn.service.d.ts +39 -0
  88. package/dist/services/agent-spawn.service.d.ts.map +1 -0
  89. package/dist/services/agent-spawn.service.js +348 -0
  90. package/dist/services/agent-spawn.service.js.map +1 -0
  91. package/dist/services/background-runner.d.ts +26 -0
  92. package/dist/services/background-runner.d.ts.map +1 -0
  93. package/dist/services/background-runner.js +71 -0
  94. package/dist/services/background-runner.js.map +1 -0
  95. package/dist/services/consolidation.service.d.ts +16 -0
  96. package/dist/services/consolidation.service.d.ts.map +1 -0
  97. package/dist/services/consolidation.service.js +508 -0
  98. package/dist/services/consolidation.service.js.map +1 -0
  99. package/dist/services/dispatcher/broker.d.ts +2 -0
  100. package/dist/services/dispatcher/broker.d.ts.map +1 -1
  101. package/dist/services/dispatcher/broker.js +5 -10
  102. package/dist/services/dispatcher/broker.js.map +1 -1
  103. package/dist/services/dispatcher/index.d.ts +1 -1
  104. package/dist/services/dispatcher/index.d.ts.map +1 -1
  105. package/dist/services/dispatcher/index.js +1 -1
  106. package/dist/services/dispatcher/index.js.map +1 -1
  107. package/dist/services/dispatcher/lock.d.ts.map +1 -1
  108. package/dist/services/dispatcher/lock.js +7 -11
  109. package/dist/services/dispatcher/lock.js.map +1 -1
  110. package/dist/services/maintenance-signal.d.ts +18 -0
  111. package/dist/services/maintenance-signal.d.ts.map +1 -0
  112. package/dist/services/maintenance-signal.js +48 -0
  113. package/dist/services/maintenance-signal.js.map +1 -0
  114. package/dist/services/memory-briefing.service.d.ts +4 -0
  115. package/dist/services/memory-briefing.service.d.ts.map +1 -0
  116. package/dist/services/memory-briefing.service.js +143 -0
  117. package/dist/services/memory-briefing.service.js.map +1 -0
  118. package/dist/services/process.service.d.ts +31 -0
  119. package/dist/services/process.service.d.ts.map +1 -0
  120. package/dist/services/process.service.js +100 -0
  121. package/dist/services/process.service.js.map +1 -0
  122. package/dist/services/thread-health.service.d.ts +18 -0
  123. package/dist/services/thread-health.service.d.ts.map +1 -0
  124. package/dist/services/thread-health.service.js +118 -0
  125. package/dist/services/thread-health.service.js.map +1 -0
  126. package/dist/services/thread-lifecycle.service.d.ts +52 -0
  127. package/dist/services/thread-lifecycle.service.d.ts.map +1 -0
  128. package/dist/services/thread-lifecycle.service.js +174 -0
  129. package/dist/services/thread-lifecycle.service.js.map +1 -0
  130. package/dist/services/topic.service.d.ts +25 -0
  131. package/dist/services/topic.service.d.ts.map +1 -0
  132. package/dist/services/topic.service.js +65 -0
  133. package/dist/services/topic.service.js.map +1 -0
  134. package/dist/services/worker-cleanup.service.d.ts +8 -0
  135. package/dist/services/worker-cleanup.service.d.ts.map +1 -0
  136. package/dist/services/worker-cleanup.service.js +82 -0
  137. package/dist/services/worker-cleanup.service.js.map +1 -0
  138. package/dist/sessions.d.ts +14 -0
  139. package/dist/sessions.d.ts.map +1 -1
  140. package/dist/sessions.js +55 -0
  141. package/dist/sessions.js.map +1 -1
  142. package/dist/telegram.d.ts +13 -6
  143. package/dist/telegram.d.ts.map +1 -1
  144. package/dist/telegram.js +43 -14
  145. package/dist/telegram.js.map +1 -1
  146. package/dist/tools/defs/memory-defs.d.ts.map +1 -1
  147. package/dist/tools/defs/memory-defs.js +0 -19
  148. package/dist/tools/defs/memory-defs.js.map +1 -1
  149. package/dist/tools/delegate-tool.d.ts +4 -0
  150. package/dist/tools/delegate-tool.d.ts.map +1 -1
  151. package/dist/tools/delegate-tool.js +48 -109
  152. package/dist/tools/delegate-tool.js.map +1 -1
  153. package/dist/tools/memory-tools.d.ts.map +1 -1
  154. package/dist/tools/memory-tools.js +1 -16
  155. package/dist/tools/memory-tools.js.map +1 -1
  156. package/dist/tools/shared-agent-utils.d.ts +9 -1
  157. package/dist/tools/shared-agent-utils.d.ts.map +1 -1
  158. package/dist/tools/shared-agent-utils.js +24 -42
  159. package/dist/tools/shared-agent-utils.js.map +1 -1
  160. package/dist/tools/start-session-tool.d.ts +2 -0
  161. package/dist/tools/start-session-tool.d.ts.map +1 -1
  162. package/dist/tools/start-session-tool.js +66 -106
  163. package/dist/tools/start-session-tool.js.map +1 -1
  164. package/dist/tools/thread-lifecycle.d.ts +5 -127
  165. package/dist/tools/thread-lifecycle.d.ts.map +1 -1
  166. package/dist/tools/thread-lifecycle.js +5 -1163
  167. package/dist/tools/thread-lifecycle.js.map +1 -1
  168. package/dist/tools/utility-tools.js +5 -2
  169. package/dist/tools/utility-tools.js.map +1 -1
  170. package/dist/tools/wait/drive-handler.d.ts +0 -1
  171. package/dist/tools/wait/drive-handler.d.ts.map +1 -1
  172. package/dist/tools/wait/drive-handler.js +5 -22
  173. package/dist/tools/wait/drive-handler.js.map +1 -1
  174. package/dist/tools/wait/message-delivery.js +1 -1
  175. package/dist/tools/wait/message-delivery.js.map +1 -1
  176. package/dist/tools/wait/message-processing.d.ts.map +1 -1
  177. package/dist/tools/wait/message-processing.js +9 -8
  178. package/dist/tools/wait/message-processing.js.map +1 -1
  179. package/dist/tools/wait/poll-loop.d.ts +2 -0
  180. package/dist/tools/wait/poll-loop.d.ts.map +1 -1
  181. package/dist/tools/wait/poll-loop.js +27 -29
  182. package/dist/tools/wait/poll-loop.js.map +1 -1
  183. package/dist/tools/wait/task-handler.d.ts +0 -3
  184. package/dist/tools/wait/task-handler.d.ts.map +1 -1
  185. package/dist/tools/wait/task-handler.js +3 -2
  186. package/dist/tools/wait/task-handler.js.map +1 -1
  187. package/dist/types.d.ts +0 -1
  188. package/dist/types.d.ts.map +1 -1
  189. package/package.json +4 -8
  190. package/supervisor/config.go +182 -69
  191. package/supervisor/config_test.go +78 -0
  192. package/supervisor/go.mod +12 -0
  193. package/supervisor/go.sum +20 -0
  194. package/supervisor/health.go +60 -11
  195. package/supervisor/health_test.go +29 -0
  196. package/supervisor/keeper.go +15 -10
  197. package/supervisor/log.go +109 -28
  198. package/supervisor/log_test.go +86 -6
  199. package/supervisor/main.go +150 -19
  200. package/supervisor/main_test.go +130 -0
  201. package/supervisor/process.go +47 -4
  202. package/supervisor/process_test.go +14 -0
  203. package/supervisor/secrets.go +95 -0
  204. package/supervisor/secrets_securevault_test.go +98 -0
  205. package/supervisor/secrets_test.go +119 -0
  206. package/supervisor/self_update.go +282 -0
  207. package/supervisor/self_update_test.go +177 -0
  208. package/supervisor/service_restart_stub.go +9 -0
  209. package/supervisor/service_restart_windows.go +63 -0
  210. package/supervisor/service_stub.go +15 -0
  211. package/supervisor/service_windows.go +216 -0
  212. package/supervisor/update_state.go +264 -0
  213. package/supervisor/update_state_test.go +306 -0
  214. package/supervisor/updater.go +311 -10
  215. package/supervisor/updater_test.go +64 -0
  216. package/dist/data/memory/quality-scoring.d.ts +0 -32
  217. package/dist/data/memory/quality-scoring.d.ts.map +0 -1
  218. package/dist/data/memory/quality-scoring.js +0 -182
  219. package/dist/data/memory/quality-scoring.js.map +0 -1
  220. package/scripts/install-supervisor.ps1 +0 -67
  221. package/scripts/install-supervisor.sh +0 -43
  222. package/scripts/start-supervisor.ps1 +0 -46
  223. package/scripts/start-supervisor.sh +0 -20
  224. package/templates/coding-task.default.md +0 -12
@@ -22,7 +22,7 @@ func NewMCPClient(port int, secret string) *MCPClient {
22
22
  return &MCPClient{
23
23
  BaseURL: fmt.Sprintf("http://127.0.0.1:%d", port),
24
24
  Secret: secret,
25
- Client: &http.Client{Timeout: 35 * time.Second},
25
+ Client: &http.Client{Timeout: 10 * time.Second},
26
26
  }
27
27
  }
28
28
 
@@ -98,15 +98,57 @@ func (m *MCPClient) WaitForReady(ctx context.Context, pollInterval, timeout time
98
98
 
99
99
  // GetRootThreads fetches the list of root threads from the server.
100
100
  func (m *MCPClient) GetRootThreads(ctx context.Context) ([]map[string]any, error) {
101
+ return m.fetchThreadList(ctx, "/api/threads/roots")
102
+ }
103
+
104
+ // GetKeepAliveThreads fetches all threads with keepAlive=true (excluding worker threads).
105
+ func (m *MCPClient) GetKeepAliveThreads(ctx context.Context) ([]map[string]any, error) {
106
+ threads, err := m.fetchThreadList(ctx, "/api/threads/keepalive")
107
+ if err == nil {
108
+ return threads, nil
109
+ }
110
+
111
+ // Backward compatibility: older MCP builds may not expose /api/threads/keepalive.
112
+ if strings.Contains(err.Error(), "GET /api/threads/keepalive: 404") {
113
+ if m.Log != nil {
114
+ m.Log.Warn("/api/threads/keepalive unavailable (404); falling back to /api/threads with client-side filtering")
115
+ }
116
+ allThreads, err2 := m.fetchThreadList(ctx, "/api/threads")
117
+ if err2 != nil {
118
+ return nil, err2
119
+ }
120
+ return filterKeepAliveThreads(allThreads), nil
121
+ }
122
+
123
+ return nil, err
124
+ }
125
+
126
+ func filterKeepAliveThreads(threads []map[string]any) []map[string]any {
127
+ result := make([]map[string]any, 0, len(threads))
128
+ for _, t := range threads {
129
+ keepAlive, _ := t["keepAlive"].(bool)
130
+ if !keepAlive {
131
+ continue
132
+ }
133
+ if typ, _ := t["type"].(string); typ == "worker" {
134
+ continue
135
+ }
136
+ result = append(result, t)
137
+ }
138
+ return result
139
+ }
140
+
141
+ // fetchThreadList is a shared helper for thread-list endpoints.
142
+ func (m *MCPClient) fetchThreadList(ctx context.Context, path string) ([]map[string]any, error) {
101
143
  ctx2, cancel := context.WithTimeout(ctx, 5*time.Second)
102
144
  defer cancel()
103
- resp, err := m.doReq(ctx2, "GET", "/api/threads/roots", nil)
145
+ resp, err := m.doReq(ctx2, "GET", path, nil)
104
146
  if err != nil {
105
147
  return nil, err
106
148
  }
107
149
  defer resp.Body.Close()
108
150
  if resp.StatusCode != 200 {
109
- return nil, fmt.Errorf("GET /api/threads/roots: %d", resp.StatusCode)
151
+ return nil, fmt.Errorf("GET %s: %d", path, resp.StatusCode)
110
152
  }
111
153
  body, err := io.ReadAll(resp.Body)
112
154
  if err != nil {
@@ -122,7 +164,7 @@ func (m *MCPClient) GetRootThreads(ctx context.Context) ([]map[string]any, error
122
164
  Threads []map[string]any `json:"threads"`
123
165
  }
124
166
  if err := json.Unmarshal(body, &wrapped); err != nil {
125
- return nil, fmt.Errorf("cannot parse /api/threads/roots response: %w", err)
167
+ return nil, fmt.Errorf("cannot parse %s response: %w", path, err)
126
168
  }
127
169
  return wrapped.Threads, nil
128
170
  }
@@ -145,7 +187,9 @@ func (m *MCPClient) IsThreadRunning(ctx context.Context, threadID int) bool {
145
187
  }
146
188
  return false
147
189
  }
148
- var result struct{ Running bool `json:"running"` }
190
+ var result struct {
191
+ Running bool `json:"running"`
192
+ }
149
193
  if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
150
194
  if m.Log != nil {
151
195
  m.Log.Debug("IsThreadRunning(%d): decode error: %v", threadID, err)
@@ -272,7 +316,10 @@ func (m *MCPClient) OpenMCPSession(ctx context.Context) (string, error) {
272
316
  "jsonrpc": "2.0",
273
317
  "method": "notifications/initialized",
274
318
  }
275
- notifReq, _ := http.NewRequestWithContext(ctx2, "POST", m.BaseURL+"/mcp", nil)
319
+ notifReq, err := http.NewRequestWithContext(ctx2, "POST", m.BaseURL+"/mcp", nil)
320
+ if err != nil {
321
+ return sessionID, nil // session created, notification failed — non-fatal
322
+ }
276
323
  data, err := json.Marshal(notifPayload)
277
324
  if err != nil {
278
325
  return sessionID, nil // session created, notification failed — non-fatal
@@ -297,7 +344,10 @@ func (m *MCPClient) CloseMCPSession(ctx context.Context, sessionID string) {
297
344
  }
298
345
  ctx2, cancel := context.WithTimeout(ctx, 5*time.Second)
299
346
  defer cancel()
300
- req, _ := http.NewRequestWithContext(ctx2, "DELETE", m.BaseURL+"/mcp", nil)
347
+ req, err := http.NewRequestWithContext(ctx2, "DELETE", m.BaseURL+"/mcp", nil)
348
+ if err != nil {
349
+ return
350
+ }
301
351
  for k, v := range m.authHeaders() {
302
352
  req.Header.Set(k, v)
303
353
  }
@@ -317,10 +367,9 @@ func (m *MCPClient) CallStartThread(ctx context.Context, sessionID string, threa
317
367
  defer cancel()
318
368
 
319
369
  args := map[string]any{
320
- "targetThreadId": threadID,
321
- "mode": "resume",
322
- "name": sessionName,
323
- "agentType": client,
370
+ "threadId": threadID,
371
+ "name": sessionName,
372
+ "agentType": client,
324
373
  }
325
374
  if workingDir != "" {
326
375
  args["workingDirectory"] = workingDir
@@ -91,3 +91,32 @@ func TestIsThreadRunning_False(t *testing.T) {
91
91
  t.Error("expected thread to not be running")
92
92
  }
93
93
  }
94
+
95
+ func TestGetKeepAliveThreads_FallbackToThreadsEndpoint(t *testing.T) {
96
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
97
+ w.Header().Set("Content-Type", "application/json")
98
+ switch r.URL.Path {
99
+ case "/api/threads/keepalive":
100
+ w.WriteHeader(http.StatusNotFound)
101
+ w.Write([]byte(`{"error":"not found"}`))
102
+ case "/api/threads":
103
+ w.WriteHeader(http.StatusOK)
104
+ w.Write([]byte(`{"threads":[{"threadId":1,"name":"root","type":"root","keepAlive":true},{"threadId":2,"name":"worker","type":"worker","keepAlive":true},{"threadId":3,"name":"off","type":"root","keepAlive":false}]}`))
105
+ default:
106
+ w.WriteHeader(http.StatusNotFound)
107
+ }
108
+ }))
109
+ defer srv.Close()
110
+
111
+ mcp := &MCPClient{BaseURL: srv.URL, Client: srv.Client()}
112
+ threads, err := mcp.GetKeepAliveThreads(context.Background())
113
+ if err != nil {
114
+ t.Fatalf("unexpected error: %v", err)
115
+ }
116
+ if len(threads) != 1 {
117
+ t.Fatalf("got %d threads, want 1", len(threads))
118
+ }
119
+ if got, _ := threads[0]["threadId"].(float64); int(got) != 1 {
120
+ t.Fatalf("unexpected thread returned: %v", threads[0])
121
+ }
122
+ }
@@ -5,6 +5,7 @@ import (
5
5
  "encoding/json"
6
6
  "fmt"
7
7
  "math"
8
+ "path/filepath"
8
9
  "sync"
9
10
  "time"
10
11
  )
@@ -119,9 +120,11 @@ func (k *Keeper) run(ctx context.Context) {
119
120
  if retryCount > 0 {
120
121
  k.log.Info("Thread %d is healthy again (was at retry %d)", k.cfg.ThreadID, retryCount)
121
122
  } else {
122
- k.log.Debug("Thread %d is healthy", k.cfg.ThreadID)
123
+ k.log.Info("Thread %d is healthy", k.cfg.ThreadID)
123
124
  }
124
125
  retryCount = 0
126
+ fastExitCount = 0
127
+ fastExitEscalation = 0
125
128
  return
126
129
  }
127
130
  }
@@ -243,14 +246,14 @@ func (k *Keeper) callStartThread(ctx context.Context) (bool, int) {
243
246
  func (k *Keeper) killThread(ctx context.Context, threadID int) {
244
247
  k.log.Info("Killing stuck thread %d", threadID)
245
248
  // Read PID from thread PID file
246
- pidFile := k.global.Paths.PIDsDir + "/" + fmt.Sprintf("%d.pid", threadID)
249
+ pidFile := filepath.Join(k.global.Paths.PIDsDir, fmt.Sprintf("%d.pid", threadID))
247
250
  pid, err := ReadPIDFile(pidFile)
248
251
  if err != nil {
249
- k.log.Warn("Cannot read PID for thread %d: %v", k.cfg.ThreadID, err)
252
+ k.log.Warn("Cannot read PID for thread %d: %v", threadID, err)
250
253
  return
251
254
  }
252
255
  if err := KillProcess(pid, k.log); err != nil {
253
- k.log.Error("Failed to kill thread %d (PID %d): %v", k.cfg.ThreadID, pid, err)
256
+ k.log.Error("Failed to kill thread %d (PID %d): %v", threadID, pid, err)
254
257
  }
255
258
  }
256
259
 
@@ -269,14 +272,16 @@ func (k *Keeper) sleep(ctx context.Context, d time.Duration) {
269
272
  }
270
273
  }
271
274
 
272
- // isRootKeepAlive checks whether the root thread still has keepAlive=true.
275
+ // isRootKeepAlive checks whether the thread still has keepAlive=true.
276
+ // Uses the /api/threads/keepalive endpoint (root, branch, daily — excludes workers)
277
+ // so that branch and daily threads are correctly included in the check.
273
278
  func (k *Keeper) isRootKeepAlive(ctx context.Context) bool {
274
- roots, err := k.mcp.GetRootThreads(ctx)
279
+ threads, err := k.mcp.GetKeepAliveThreads(ctx)
275
280
  if err != nil {
276
- k.log.Debug("isRootKeepAlive(%d): failed to fetch roots: %v — assuming still alive", k.cfg.ThreadID, err)
281
+ k.log.Debug("isRootKeepAlive(%d): failed to fetch keepalive threads: %v — assuming still alive", k.cfg.ThreadID, err)
277
282
  return true // fail-open: don't stop keeper if we can't check
278
283
  }
279
- for _, r := range roots {
284
+ for _, r := range threads {
280
285
  tidFloat, _ := r["threadId"].(float64)
281
286
  if int(tidFloat) == k.cfg.ThreadID {
282
287
  keepAlive, _ := r["keepAlive"].(bool)
@@ -284,8 +289,8 @@ func (k *Keeper) isRootKeepAlive(ctx context.Context) bool {
284
289
  return keepAlive && (status == "" || status == "active")
285
290
  }
286
291
  }
287
- k.log.Debug("isRootKeepAlive(%d): root thread not found in response", k.cfg.ThreadID)
288
- return false // root thread gone → stop keeper
292
+ k.log.Debug("isRootKeepAlive(%d): thread not found in keepalive list", k.cfg.ThreadID)
293
+ return false // thread gone or keepAlive removed → stop keeper
289
294
  }
290
295
 
291
296
  // parseWorkerThreadID extracts the "threadId" field from a start_thread JSON response.
package/supervisor/log.go CHANGED
@@ -3,30 +3,40 @@ package main
3
3
  import (
4
4
  "fmt"
5
5
  "os"
6
+ "path/filepath"
7
+ "sort"
8
+ "strings"
6
9
  "sync"
7
10
  "time"
8
11
  )
9
12
 
10
13
  // Logger writes to both stderr and a rotating log file.
11
- // Rotates when the file exceeds maxSize bytes.
14
+ // Rotates daily (at midnight) and when the file exceeds maxSize bytes.
12
15
  type Logger struct {
13
- mu sync.Mutex
14
- logPath string
15
- file *os.File
16
- debug bool
17
- size int64
18
- maxSize int64 // default 5 MB
19
- maxKeep int // max rotated files to keep
16
+ mu sync.Mutex
17
+ logPath string
18
+ file *os.File
19
+ debug bool
20
+ size int64
21
+ maxSize int64 // default 5 MB
22
+ maxKeep int // max daily rotated files to keep
23
+ today string
24
+ stopTimer chan struct{}
20
25
  }
21
26
 
22
27
  func NewLogger(logPath string) *Logger {
23
28
  l := &Logger{
24
- logPath: logPath,
25
- debug: os.Getenv("SUPERVISOR_DEBUG") == "1" || os.Getenv("SUPERVISOR_DEBUG") == "true",
26
- maxSize: 5 * 1024 * 1024, // 5 MB
27
- maxKeep: 3,
29
+ logPath: logPath,
30
+ debug: os.Getenv("SUPERVISOR_DEBUG") == "1" || os.Getenv("SUPERVISOR_DEBUG") == "true",
31
+ maxSize: 5 * 1024 * 1024, // 5 MB
32
+ maxKeep: 7, // keep 7 daily files
33
+ stopTimer: make(chan struct{}),
28
34
  }
35
+ // Rotate previous day's log on startup if needed
36
+ l.today = time.Now().Format("2006-01-02")
37
+ l.rotateDailyIfNeeded()
29
38
  l.openFile()
39
+ l.startMidnightTimer()
30
40
  return l
31
41
  }
32
42
 
@@ -53,7 +63,11 @@ func (l *Logger) log(level, format string, args ...any) {
53
63
 
54
64
  l.mu.Lock()
55
65
  defer l.mu.Unlock()
56
- fmt.Fprint(os.Stderr, line)
66
+ // Always write to file for post-mortem debugging; only emit DEBUG to stderr
67
+ // when SUPERVISOR_DEBUG is set.
68
+ if level != "DEBUG" || l.debug {
69
+ fmt.Fprint(os.Stderr, line)
70
+ }
57
71
  if l.file != nil {
58
72
  n, err := l.file.WriteString(line)
59
73
  if err != nil {
@@ -69,30 +83,93 @@ func (l *Logger) log(level, format string, args ...any) {
69
83
  func (l *Logger) Info(format string, args ...any) { l.log("INFO", format, args...) }
70
84
  func (l *Logger) Warn(format string, args ...any) { l.log("WARN", format, args...) }
71
85
  func (l *Logger) Error(format string, args ...any) { l.log("ERROR", format, args...) }
72
- func (l *Logger) Debug(format string, args ...any) {
73
- if l.debug {
74
- l.log("DEBUG", format, args...)
86
+ func (l *Logger) Debug(format string, args ...any) { l.log("DEBUG", format, args...) }
87
+
88
+ // rotateDailyIfNeeded renames the current log to a dated archive if it was
89
+ // written on a previous day. Called without mu held (used at startup and from
90
+ // the midnight timer before the lock is acquired).
91
+ func (l *Logger) rotateDailyIfNeeded() {
92
+ info, err := os.Stat(l.logPath)
93
+ if err != nil {
94
+ return // file doesn't exist yet — nothing to rotate
95
+ }
96
+ modDay := info.ModTime().Format("2006-01-02")
97
+ if modDay == l.today {
98
+ return // same day — no rotation needed
99
+ }
100
+ // Rename to dated file
101
+ ext := filepath.Ext(l.logPath)
102
+ base := strings.TrimSuffix(l.logPath, ext)
103
+ dated := fmt.Sprintf("%s.%s%s", base, modDay, ext)
104
+ if err := os.Rename(l.logPath, dated); err != nil {
105
+ fmt.Fprintf(os.Stderr, "[WARN] daily log rotate: %v\n", err)
75
106
  }
107
+ l.pruneOldLogs()
76
108
  }
77
109
 
78
- // rotate closes the current log file, renames it with a .1 suffix (shifting
79
- // older rotated files), and opens a fresh log. Called with mu held.
110
+ // pruneOldLogs deletes daily log files beyond maxKeep. Called without mu held.
111
+ func (l *Logger) pruneOldLogs() {
112
+ dir := filepath.Dir(l.logPath)
113
+ base := strings.TrimSuffix(filepath.Base(l.logPath), filepath.Ext(l.logPath))
114
+ entries, err := os.ReadDir(dir)
115
+ if err != nil {
116
+ return
117
+ }
118
+ var dated []string
119
+ for _, e := range entries {
120
+ name := e.Name()
121
+ if strings.HasPrefix(name, base+".") && name != filepath.Base(l.logPath) {
122
+ dated = append(dated, filepath.Join(dir, name))
123
+ }
124
+ }
125
+ sort.Strings(dated) // ascending — oldest first
126
+ for len(dated) > l.maxKeep {
127
+ os.Remove(dated[0])
128
+ dated = dated[1:]
129
+ }
130
+ }
131
+
132
+ // startMidnightTimer fires a daily rotation at local midnight.
133
+ func (l *Logger) startMidnightTimer() {
134
+ stopCh := l.stopTimer // capture channel value; Close() may nil the field concurrently
135
+ go func() {
136
+ for {
137
+ now := time.Now()
138
+ next := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, now.Location())
139
+ select {
140
+ case <-time.After(time.Until(next)):
141
+ case <-stopCh:
142
+ return
143
+ }
144
+ l.mu.Lock()
145
+ l.today = time.Now().Format("2006-01-02")
146
+ if l.file != nil {
147
+ l.file.Close()
148
+ l.file = nil
149
+ }
150
+ l.rotateDailyIfNeeded()
151
+ l.size = 0
152
+ l.openFile()
153
+ l.mu.Unlock()
154
+ }
155
+ }()
156
+ }
157
+
158
+ // rotate closes the current log file, renames it with a dated suffix for
159
+ // size-based rotation mid-day, and opens a fresh log. Called with mu held.
80
160
  func (l *Logger) rotate() {
81
161
  if l.file != nil {
82
162
  l.file.Close()
83
163
  l.file = nil
84
164
  }
85
-
86
- // Shift existing rotated logs: .3 → delete, .2 → .3, .1 → .2, current → .1
87
- for i := l.maxKeep; i >= 1; i-- {
88
- old := fmt.Sprintf("%s.%d", l.logPath, i)
89
- if i == l.maxKeep {
90
- os.Remove(old)
91
- } else {
92
- os.Rename(old, fmt.Sprintf("%s.%d", l.logPath, i+1))
93
- }
165
+ // Use a timestamp suffix to avoid colliding with the daily dated file
166
+ ts := time.Now().Format("2006-01-02T150405")
167
+ ext := filepath.Ext(l.logPath)
168
+ base := strings.TrimSuffix(l.logPath, ext)
169
+ if err := os.Rename(l.logPath, fmt.Sprintf("%s.%s%s", base, ts, ext)); err != nil {
170
+ fmt.Fprintf(os.Stderr, "[WARN] log size-rotate rename: %v\n", err)
94
171
  }
95
- os.Rename(l.logPath, l.logPath+".1")
172
+ l.pruneOldLogs()
96
173
 
97
174
  // Open a fresh file
98
175
  l.size = 0
@@ -105,6 +182,10 @@ func (l *Logger) rotate() {
105
182
  }
106
183
 
107
184
  func (l *Logger) Close() {
185
+ if l.stopTimer != nil {
186
+ close(l.stopTimer)
187
+ l.stopTimer = nil
188
+ }
108
189
  l.mu.Lock()
109
190
  defer l.mu.Unlock()
110
191
  if l.file != nil {
@@ -33,13 +33,93 @@ func TestLogRotation(t *testing.T) {
33
33
  t.Errorf("log file should have been rotated, size=%d", info.Size())
34
34
  }
35
35
 
36
- // At least .1 should exist
37
- if _, err := os.Stat(logPath + ".1"); err != nil {
38
- t.Error("expected .1 rotated file to exist")
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)
39
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()
40
109
 
41
- // .3 should NOT exist (maxKeep=2)
42
- if _, err := os.Stat(logPath + ".3"); err == nil {
43
- t.Error("expected .3 file to not exist (maxKeep=2)")
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)
44
124
  }
45
125
  }