@geekbeer/minion 3.51.2 → 3.52.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.
package/core/api.js CHANGED
@@ -6,6 +6,16 @@
6
6
  */
7
7
 
8
8
  const { config, isHqConfigured } = require('./config')
9
+ const frozenState = require('./lib/frozen-state')
10
+
11
+ class BillingFrozenError extends Error {
12
+ constructor(reason) {
13
+ super(`Minion is billing-frozen: ${reason || 'unknown'}`)
14
+ this.name = 'BillingFrozenError'
15
+ this.statusCode = 402
16
+ this.billingFrozen = true
17
+ }
18
+ }
9
19
 
10
20
  /**
11
21
  * Send HTTP request to the HQ server
@@ -17,6 +27,10 @@ async function request(endpoint, options = {}) {
17
27
  return { skipped: true, reason: 'HQ not configured' }
18
28
  }
19
29
 
30
+ if (frozenState.isFrozen()) {
31
+ throw new BillingFrozenError(frozenState.getState().reason)
32
+ }
33
+
20
34
  const url = `${config.HQ_URL}/api/minion${endpoint}`
21
35
 
22
36
  const response = await fetch(url, {
@@ -28,10 +42,19 @@ async function request(endpoint, options = {}) {
28
42
  },
29
43
  })
30
44
 
31
- const data = await response.json()
45
+ let data = null
46
+ try {
47
+ data = await response.json()
48
+ } catch {
49
+ data = {}
50
+ }
51
+
52
+ if (frozenState.maybeFreezeFromResponse(response, data)) {
53
+ throw new BillingFrozenError(data && data.reason)
54
+ }
32
55
 
33
56
  if (!response.ok) {
34
- const err = new Error(data.error || `API request failed: ${response.status}`)
57
+ const err = new Error((data && data.error) || `API request failed: ${response.status}`)
35
58
  err.statusCode = response.status
36
59
  throw err
37
60
  }
@@ -203,4 +226,5 @@ module.exports = {
203
226
  deleteThread,
204
227
  createProjectMemory,
205
228
  searchProjectMemories,
229
+ BillingFrozenError,
206
230
  }
@@ -21,6 +21,8 @@
21
21
 
22
22
  const { config, isHqConfigured } = require('../config')
23
23
  const concurrency = require('./concurrency-manager')
24
+ const frozenState = require('./frozen-state')
25
+ const api = require('../api')
24
26
 
25
27
  // Polling interval: 30 seconds (matches dag-step-poller).
26
28
  const POLL_INTERVAL_MS = 30_000
@@ -45,6 +47,9 @@ async function hqRequest(endpoint, options = {}) {
45
47
  if (!isHqConfigured()) {
46
48
  return { skipped: true, reason: 'HQ not configured' }
47
49
  }
50
+ if (frozenState.isFrozen()) {
51
+ throw new api.BillingFrozenError(frozenState.getState().reason)
52
+ }
48
53
  const url = `${config.HQ_URL}${endpoint}`
49
54
  const resp = await fetch(url, {
50
55
  ...options,
@@ -61,6 +66,9 @@ async function hqRequest(endpoint, options = {}) {
61
66
  } catch {
62
67
  data = { raw: text }
63
68
  }
69
+ if (frozenState.maybeFreezeFromResponse(resp, data)) {
70
+ throw new api.BillingFrozenError(data && data.reason)
71
+ }
64
72
  if (!resp.ok) {
65
73
  const err = new Error(data.error || `HQ ${endpoint} failed: ${resp.status}`)
66
74
  err.statusCode = resp.status
@@ -72,6 +80,7 @@ async function hqRequest(endpoint, options = {}) {
72
80
 
73
81
  async function pollOnce() {
74
82
  if (!isHqConfigured()) return
83
+ if (frozenState.isFrozen()) return
75
84
  if (!runner) {
76
85
  console.warn('[BoardTaskPoller] No runner injected, skipping poll')
77
86
  return
@@ -117,7 +126,9 @@ async function pollOnce() {
117
126
  })
118
127
  }
119
128
  } catch (err) {
120
- if (err.message?.includes('fetch failed') || err.message?.includes('ECONNREFUSED')) {
129
+ if (err.billingFrozen) {
130
+ console.log('[BoardTaskPoller] Billing frozen, suspending poll')
131
+ } else if (err.message?.includes('fetch failed') || err.message?.includes('ECONNREFUSED')) {
121
132
  console.log('[BoardTaskPoller] HQ unreachable, will retry next cycle')
122
133
  } else {
123
134
  console.error(`[BoardTaskPoller] Poll error: ${err.message}`)
@@ -12,6 +12,8 @@
12
12
  */
13
13
 
14
14
  const { config, isHqConfigured } = require('../config')
15
+ const frozenState = require('./frozen-state')
16
+ const api = require('../api')
15
17
 
16
18
  const POLL_INTERVAL_MS = 60_000
17
19
 
@@ -22,6 +24,7 @@ let lastFiredCount = 0
22
24
 
23
25
  async function pollOnce() {
24
26
  if (!isHqConfigured()) return
27
+ if (frozenState.isFrozen()) return
25
28
  if (polling) return
26
29
 
27
30
  polling = true
@@ -35,6 +38,14 @@ async function pollOnce() {
35
38
  },
36
39
  })
37
40
 
41
+ let payload = null
42
+ if (resp.status === 402) {
43
+ try { payload = await resp.json() } catch { payload = {} }
44
+ if (frozenState.maybeFreezeFromResponse(resp, payload)) {
45
+ throw new api.BillingFrozenError(payload && payload.reason)
46
+ }
47
+ }
48
+
38
49
  if (!resp.ok) {
39
50
  throw new Error(`dag-cron-tick failed: ${resp.status}`)
40
51
  }
@@ -58,7 +69,9 @@ async function pollOnce() {
58
69
  }
59
70
  }
60
71
  } catch (err) {
61
- if (err.message?.includes('fetch failed') || err.message?.includes('ECONNREFUSED')) {
72
+ if (err.billingFrozen) {
73
+ console.log('[DagCronPoller] Billing frozen, suspending poll')
74
+ } else if (err.message?.includes('fetch failed') || err.message?.includes('ECONNREFUSED')) {
62
75
  console.log('[DagCronPoller] HQ unreachable, will retry next cycle')
63
76
  } else {
64
77
  console.error(`[DagCronPoller] Poll error: ${err.message}`)
@@ -13,6 +13,7 @@ const { config, isHqConfigured } = require('../config')
13
13
  const api = require('../api')
14
14
  const variableStore = require('../stores/variable-store')
15
15
  const concurrency = require('./concurrency-manager')
16
+ const frozenState = require('./frozen-state')
16
17
 
17
18
  // Polling interval: 30 seconds (matches step-poller)
18
19
  const POLL_INTERVAL_MS = 30_000
@@ -38,6 +39,10 @@ async function dagRequest(endpoint, options = {}) {
38
39
  return { skipped: true, reason: 'HQ not configured' }
39
40
  }
40
41
 
42
+ if (frozenState.isFrozen()) {
43
+ throw new api.BillingFrozenError(frozenState.getState().reason)
44
+ }
45
+
41
46
  const url = `${config.HQ_URL}/api/dag/minion${endpoint}`
42
47
  const resp = await fetch(url, {
43
48
  ...options,
@@ -48,6 +53,14 @@ async function dagRequest(endpoint, options = {}) {
48
53
  },
49
54
  })
50
55
 
56
+ let payload = null
57
+ if (resp.status === 402) {
58
+ try { payload = await resp.json() } catch { payload = {} }
59
+ if (frozenState.maybeFreezeFromResponse(resp, payload)) {
60
+ throw new api.BillingFrozenError(payload && payload.reason)
61
+ }
62
+ }
63
+
51
64
  if (!resp.ok) {
52
65
  const err = new Error(`DAG API ${endpoint} failed: ${resp.status}`)
53
66
  err.statusCode = resp.status
@@ -62,6 +75,7 @@ async function dagRequest(endpoint, options = {}) {
62
75
  */
63
76
  async function pollOnce() {
64
77
  if (!isHqConfigured()) return
78
+ if (frozenState.isFrozen()) return
65
79
  if (polling) return
66
80
 
67
81
  polling = true
@@ -90,7 +104,9 @@ async function pollOnce() {
90
104
  promise
91
105
  }
92
106
  } catch (err) {
93
- if (err.message?.includes('fetch failed') || err.message?.includes('ECONNREFUSED')) {
107
+ if (err.billingFrozen) {
108
+ console.log('[DagPoller] Billing frozen, suspending poll')
109
+ } else if (err.message?.includes('fetch failed') || err.message?.includes('ECONNREFUSED')) {
94
110
  console.log('[DagPoller] HQ unreachable, will retry next cycle')
95
111
  } else {
96
112
  console.error(`[DagPoller] Poll error: ${err.message}`)
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Frozen State Module
3
+ *
4
+ * Tracks billing-driven freeze state in-memory. When frozen, all pollers
5
+ * skip their work and the shared HTTP wrappers refuse outbound calls.
6
+ *
7
+ * State is intentionally NOT persisted to disk:
8
+ * - Recovery is driven by HQ pushing `restart-agent` after payment success.
9
+ * - Process restart naturally clears the in-memory flag.
10
+ * - On restart, if billing is still past_due, the next request will receive
11
+ * 402 from HQ and self-freeze again. Self-healing.
12
+ */
13
+
14
+ let frozen = false
15
+ let frozenAt = null
16
+ let reason = null
17
+
18
+ function isFrozen() {
19
+ return frozen
20
+ }
21
+
22
+ function setFrozen(opts = {}) {
23
+ if (frozen) return
24
+ frozen = true
25
+ frozenAt = new Date().toISOString()
26
+ reason = opts.reason || 'unknown'
27
+ console.log(`[FrozenState] Minion frozen: reason=${reason} at=${frozenAt}`)
28
+ }
29
+
30
+ function clearFrozen() {
31
+ if (!frozen) return
32
+ frozen = false
33
+ frozenAt = null
34
+ reason = null
35
+ console.log('[FrozenState] Minion unfrozen (in-memory state cleared)')
36
+ }
37
+
38
+ function getState() {
39
+ return { frozen, frozenAt, reason }
40
+ }
41
+
42
+ /**
43
+ * Inspect a fetch Response for the 402 billing-frozen signal and
44
+ * self-freeze if matched. Returns true if frozen was set.
45
+ *
46
+ * Expected payload: `{ "error": "billing_frozen", "reason": "past_due", ... }`
47
+ */
48
+ function maybeFreezeFromResponse(response, payload) {
49
+ if (response && response.status === 402) {
50
+ if (payload && payload.error === 'billing_frozen') {
51
+ setFrozen({ reason: payload.reason || 'billing_frozen' })
52
+ return true
53
+ }
54
+ }
55
+ return false
56
+ }
57
+
58
+ module.exports = {
59
+ isFrozen,
60
+ setFrozen,
61
+ clearFrozen,
62
+ getState,
63
+ maybeFreezeFromResponse,
64
+ }
@@ -15,6 +15,7 @@
15
15
 
16
16
  const { config, isHqConfigured } = require('../config')
17
17
  const api = require('../api')
18
+ const frozenState = require('./frozen-state')
18
19
 
19
20
  // Poll every 30 seconds (same frequency as step-poller)
20
21
  const POLL_INTERVAL_MS = 30_000
@@ -33,6 +34,7 @@ const processingRevisions = new Set()
33
34
  */
34
35
  async function pollOnce() {
35
36
  if (!isHqConfigured()) return
37
+ if (frozenState.isFrozen()) return
36
38
  if (polling) return
37
39
 
38
40
  polling = true
@@ -59,7 +61,9 @@ async function pollOnce() {
59
61
  }
60
62
  }
61
63
  } catch (err) {
62
- if (err.message?.includes('fetch failed') || err.message?.includes('ECONNREFUSED')) {
64
+ if (err.billingFrozen) {
65
+ console.log(`[RevisionWatcher] Billing frozen, suspending poll`)
66
+ } else if (err.message?.includes('fetch failed') || err.message?.includes('ECONNREFUSED')) {
63
67
  console.log(`[RevisionWatcher] HQ unreachable, will retry next cycle`)
64
68
  } else {
65
69
  console.error(`[RevisionWatcher] Poll error: ${err.message}`)
@@ -21,6 +21,7 @@
21
21
  const { config, isHqConfigured } = require('../config')
22
22
  const api = require('../api')
23
23
  const variableStore = require('../stores/variable-store')
24
+ const frozenState = require('./frozen-state')
24
25
 
25
26
  // Polling interval: 30 seconds (matches heartbeat frequency)
26
27
  const POLL_INTERVAL_MS = 30_000
@@ -42,6 +43,7 @@ let lastPollAt = null
42
43
  */
43
44
  async function pollOnce() {
44
45
  if (!isHqConfigured()) return
46
+ if (frozenState.isFrozen()) return
45
47
  if (polling) return
46
48
 
47
49
  polling = true
@@ -70,7 +72,9 @@ async function pollOnce() {
70
72
  }
71
73
  } catch (err) {
72
74
  // Don't log network errors at error level — they're expected when HQ is temporarily unreachable
73
- if (err.message?.includes('fetch failed') || err.message?.includes('ECONNREFUSED')) {
75
+ if (err.billingFrozen) {
76
+ console.log(`[StepPoller] Billing frozen, suspending poll`)
77
+ } else if (err.message?.includes('fetch failed') || err.message?.includes('ECONNREFUSED')) {
74
78
  console.log(`[StepPoller] HQ unreachable, will retry next cycle`)
75
79
  } else {
76
80
  console.error(`[StepPoller] Poll error: ${err.message}`)
@@ -20,6 +20,7 @@
20
20
 
21
21
  const { config, isHqConfigured, isLlmConfigured } = require('../config')
22
22
  const api = require('../api')
23
+ const frozenState = require('./frozen-state')
23
24
 
24
25
  // Poll every 15 seconds
25
26
  const POLL_INTERVAL_MS = 15_000
@@ -94,6 +95,7 @@ function isMentioned(thread, messages, myRole) {
94
95
  */
95
96
  async function pollOnce() {
96
97
  if (!isHqConfigured()) return
98
+ if (frozenState.isFrozen()) return
97
99
  if (polling) return
98
100
 
99
101
  polling = true
@@ -115,7 +117,9 @@ async function pollOnce() {
115
117
  }
116
118
  }
117
119
  } catch (err) {
118
- if (err.message?.includes('fetch failed') || err.message?.includes('ECONNREFUSED')) {
120
+ if (err.billingFrozen) {
121
+ // Billing frozen — silent
122
+ } else if (err.message?.includes('fetch failed') || err.message?.includes('ECONNREFUSED')) {
119
123
  // HQ unreachable — silent retry
120
124
  } else {
121
125
  console.error(`[ThreadWatcher] Poll error: ${err.message}`)
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Admin routes
3
+ *
4
+ * HQ-only operations pushed via Cloudflare Tunnel. Currently exposes the
5
+ * billing-driven freeze endpoint. Recovery is intentionally NOT exposed
6
+ * here — HQ recovers a frozen minion by sending the existing
7
+ * `restart-agent` command (commands.js), which terminates the process and
8
+ * lets the in-memory frozen flag clear naturally.
9
+ *
10
+ * Endpoints:
11
+ * POST /api/admin/freeze - Set in-memory frozen state
12
+ * GET /api/admin/state - Diagnostic: report frozen state
13
+ */
14
+
15
+ const { verifyToken } = require('../lib/auth')
16
+ const frozenState = require('../lib/frozen-state')
17
+
18
+ async function adminRoutes(fastify) {
19
+ fastify.post('/api/admin/freeze', async (request, reply) => {
20
+ if (!verifyToken(request)) {
21
+ reply.code(401)
22
+ return { success: false, error: 'Unauthorized' }
23
+ }
24
+
25
+ const body = request.body || {}
26
+ const reason = typeof body.reason === 'string' ? body.reason : 'past_due'
27
+
28
+ frozenState.setFrozen({ reason })
29
+
30
+ return {
31
+ success: true,
32
+ state: frozenState.getState(),
33
+ }
34
+ })
35
+
36
+ fastify.get('/api/admin/state', async (request, reply) => {
37
+ if (!verifyToken(request)) {
38
+ reply.code(401)
39
+ return { success: false, error: 'Unauthorized' }
40
+ }
41
+
42
+ return {
43
+ success: true,
44
+ state: frozenState.getState(),
45
+ }
46
+ })
47
+ }
48
+
49
+ module.exports = { adminRoutes }
@@ -705,6 +705,23 @@ Note: 既読メールは受信後90日で自動削除される。未読メール
705
705
 
706
706
  Available commands: `restart-agent`, `update-agent`, `restart-display`, `restart-all`, `status-services`
707
707
 
708
+ ### Admin (HQ → Minion only)
709
+
710
+ HQ が課金状態の変化に応じてミニオンに直接プッシュするエンドポイント。
711
+
712
+ | Method | Endpoint | Description |
713
+ |--------|----------|-------------|
714
+ | POST | `/api/admin/freeze` | ポーラーを停止しHQへの発信を抑止する。Body: `{reason}` |
715
+ | GET | `/api/admin/state` | 現在のfrozen状態を取得 |
716
+
717
+ **Freeze の挙動 (v3.52.0〜):**
718
+ - ミニオン内のグローバル `frozen=true` (in-memory only) を立てる。
719
+ - 各ポーラー (step, dag-step, board-task, thread-watcher, dag-cron, revision-watcher, heartbeat) が `pollOnce()` 直前で `isFrozen()` を確認しスキップ。
720
+ - 共通HTTPクライアント (`core/api.js`, 各pollerの `*Request`) は HQ から `402 billing_frozen` を受信した時点で自動的に `frozen=true` をセット (HQからのpushが届かなくても自己防衛)。
721
+ - 状態は**ディスク永続化されない**。プロセス再起動で自動的にクリアされ、課金復活後の `restart-agent` コマンドで自然に解除される。
722
+
723
+ 復旧は HQ が既存の `POST /api/command { command: "restart-agent" }` をプッシュすることで行う (専用 unfreeze エンドポイントは無い)。
724
+
708
725
  ---
709
726
 
710
727
  ## HQ API Endpoints (https://<HQ_URL>)
package/linux/server.js CHANGED
@@ -74,6 +74,7 @@ const { skillRoutes } = require('../core/routes/skills')
74
74
  const { workflowRoutes } = require('../core/routes/workflows')
75
75
  const { routineRoutes } = require('../core/routes/routines')
76
76
  const { authRoutes } = require('../core/routes/auth')
77
+ const { adminRoutes } = require('../core/routes/admin')
77
78
  const { variableRoutes } = require('../core/routes/variables')
78
79
  const { memoryRoutes } = require('../core/routes/memory')
79
80
  const { dailyLogRoutes } = require('../core/routes/daily-logs')
@@ -286,6 +287,7 @@ async function registerAllRoutes(app) {
286
287
  await app.register(workflowRoutes, { workflowRunner })
287
288
  await app.register(routineRoutes, { routineRunner })
288
289
  await app.register(authRoutes)
290
+ await app.register(adminRoutes)
289
291
  await app.register(variableRoutes)
290
292
  await app.register(memoryRoutes)
291
293
  await app.register(dailyLogRoutes)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geekbeer/minion",
3
- "version": "3.51.2",
3
+ "version": "3.52.0",
4
4
  "description": "AI Agent runtime for Minion - manages status and skill deployment on VPS",
5
5
  "main": "linux/server.js",
6
6
  "bin": {
package/rules/core.md CHANGED
@@ -103,7 +103,15 @@ minion-cli --version # バージョン確認
103
103
 
104
104
  `http://localhost:8080` — 認証: `Authorization: Bearer $API_TOKEN`
105
105
 
106
- 主なカテゴリ: Health, Skills, Workflows, Executions, Terminal, Files, Commands, Permissions
106
+ 主なカテゴリ: Health, Skills, Workflows, Executions, Terminal, Files, Commands, Permissions, Admin (HQ-pushed freeze)
107
+
108
+ #### Billing-Driven Freeze (v3.52.0〜)
109
+
110
+ ユーザーの決済が `past_due` に陥ると、HQ から `POST /api/admin/freeze` がプッシュされ、ミニオン全ポーラーが停止する。
111
+ - 状態は in-memory のみ (frozen.json などのディスクファイルは存在しない)。
112
+ - HQ への発信中に `402 billing_frozen` を受けた場合も自動的にfrozen化される (HQ pushが届かなくても自己防衛)。
113
+ - 復旧は HQ が `restart-agent` コマンドをプッシュ → プロセス再起動 → in-memory フラグが消えて自動再開。
114
+ - API仕様は `~/.minion/docs/api-reference.md` の「Admin」セクション参照。
107
115
 
108
116
  #### Permission Management
109
117
 
package/win/server.js CHANGED
@@ -57,6 +57,7 @@ const { skillRoutes } = require('../core/routes/skills')
57
57
  const { workflowRoutes } = require('../core/routes/workflows')
58
58
  const { routineRoutes } = require('../core/routes/routines')
59
59
  const { authRoutes } = require('../core/routes/auth')
60
+ const { adminRoutes } = require('../core/routes/admin')
60
61
  const { variableRoutes } = require('../core/routes/variables')
61
62
  const { memoryRoutes } = require('../core/routes/memory')
62
63
  const { dailyLogRoutes } = require('../core/routes/daily-logs')
@@ -221,6 +222,7 @@ async function registerRoutes(app) {
221
222
  await app.register(workflowRoutes, { workflowRunner })
222
223
  await app.register(routineRoutes, { routineRunner })
223
224
  await app.register(authRoutes)
225
+ await app.register(adminRoutes)
224
226
  await app.register(variableRoutes)
225
227
  await app.register(memoryRoutes)
226
228
  await app.register(dailyLogRoutes)