@cocorograph/hub-agent 0.6.19 → 0.6.20

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cocorograph/hub-agent",
3
- "version": "0.6.19",
3
+ "version": "0.6.20",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -389,12 +389,17 @@ class ClaudeStreamSession {
389
389
  // 走行中ターンは完走を待ち TTL 後に再チェック (走行中は絶対に撤去しない)
390
390
  if (this._busy) {
391
391
  this._idleTimer = setTimeout(tick, ttlMs)
392
+ // idle TTL は最大 7 日。タイマー単独でイベントループを生かさない (unref)。
393
+ // 本番デーモンは WS 接続等で常駐するので reap は問題なく発火し、テスト/CLI
394
+ // など他に handle が無いプロセスは即座に終了できる。
395
+ this._idleTimer.unref?.()
392
396
  return
393
397
  }
394
398
  this._idleTimer = null
395
399
  onTimeout?.()
396
400
  }
397
401
  this._idleTimer = setTimeout(tick, ttlMs)
402
+ this._idleTimer.unref?.()
398
403
  }
399
404
 
400
405
  /**
@@ -128,6 +128,67 @@ function run(cmd, args, opts = {}) {
128
128
  }
129
129
  }
130
130
 
131
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
132
+
133
+ /**
134
+ * macOS の `launchctl bootout` は非同期で、graceful shutdown を持つ hub-agent は
135
+ * 実際に launchd の登録から消えるまでに時間がかかる。直後に `bootstrap` を叩くと
136
+ * 旧サービスがまだ抜け切っておらず `Bootstrap failed: 5: Input/output error` 等で
137
+ * 失敗し、plist はディスクにあるのにロードされない (= KeepAlive 不発 → offline)
138
+ * 状態に陥る。本関数は対象 service が launchd の登録から消えるまで polling して待つ。
139
+ * (2026-05-30: install-service の bootout→bootstrap レースで offline 化する事故の修正)
140
+ *
141
+ * @param {number} uid
142
+ * @param {{ timeoutMs?: number, intervalMs?: number }} [opts]
143
+ * @returns {Promise<boolean>} 消えたら true / timeout なら false
144
+ */
145
+ async function waitUntilBootedOut(uid, opts = {}) {
146
+ const timeoutMs = opts.timeoutMs ?? 5000
147
+ const intervalMs = opts.intervalMs ?? 200
148
+ const spawn = opts.spawn ?? spawnSync
149
+ const sleepFn = opts.sleep ?? sleep
150
+ const now = opts.now ?? (() => Date.now())
151
+ const deadline = now() + timeoutMs
152
+ while (now() < deadline) {
153
+ const r = spawn("launchctl", ["print", `gui/${uid}/${PLIST_LABEL}`], {
154
+ stdio: "ignore",
155
+ })
156
+ // 非 0 = 登録に存在しない = bootout 完了
157
+ if (r.status !== 0) return true
158
+ await sleepFn(intervalMs)
159
+ }
160
+ return false
161
+ }
162
+
163
+ /**
164
+ * bootout 後の bootstrap を、過渡的失敗 (旧サービスの抜け残り等) に対して
165
+ * リトライする。`run` と違い最終的に失敗したときだけ throw する。
166
+ *
167
+ * @param {number} uid
168
+ * @param {string} dest plist パス
169
+ * @param {{ attempts?: number, intervalMs?: number }} [opts]
170
+ */
171
+ async function bootstrapWithRetry(uid, dest, opts = {}) {
172
+ const attempts = opts.attempts ?? 4
173
+ const intervalMs = opts.intervalMs ?? 400
174
+ const spawn = opts.spawn ?? spawnSync
175
+ const sleepFn = opts.sleep ?? sleep
176
+ let lastStatus = null
177
+ for (let i = 0; i < attempts; i++) {
178
+ const r = spawn("launchctl", ["bootstrap", `gui/${uid}`, dest], {
179
+ stdio: "inherit",
180
+ })
181
+ if (r.status === 0) return
182
+ // 既にロード済み (status 37 / "service already loaded") は成功とみなす
183
+ if (r.status === 37) return
184
+ lastStatus = r.status
185
+ await sleepFn(intervalMs)
186
+ }
187
+ throw new Error(
188
+ `launchctl bootstrap gui/${uid} ${dest} failed after ${attempts} attempts (last exit ${lastStatus})`
189
+ )
190
+ }
191
+
131
192
  /**
132
193
  * `which hub-agent` の結果を、launchd / systemd から長期間 exec 可能な
133
194
  * 安定パスに正規化する。pure 関数。`opts` で fs を差し替えてテスト可能。
@@ -200,10 +261,17 @@ export async function installService({ bin } = {}) {
200
261
  await fs.writeFile(dest, expanded, { mode: 0o644 })
201
262
 
202
263
  const uid = ensureUnixUid()
203
- // 既存ロード解除 → bootstrap → kickstart
264
+ // 既存ロード解除 → (登録が消えるまで待機) → bootstrap (リトライ) → kickstart
265
+ // bootout は非同期なので待たずに bootstrap するとレースで失敗し offline 化する
266
+ // (2026-05-30 の事故)。waitUntilBootedOut で確実に抜けてから bootstrap する。
204
267
  spawnSync("launchctl", ["bootout", `gui/${uid}`, dest], { stdio: "ignore" })
205
- run("launchctl", ["bootstrap", `gui/${uid}`, dest])
206
- run("launchctl", ["kickstart", "-k", `gui/${uid}/${PLIST_LABEL}`])
268
+ await waitUntilBootedOut(uid)
269
+ await bootstrapWithRetry(uid, dest)
270
+ // kickstart は「既に走っている daemon を再起動」する用途。bootstrap + RunAtLoad で
271
+ // 既に起動済みのことがあり、その場合 kickstart 失敗は致命的でないので throw しない。
272
+ spawnSync("launchctl", ["kickstart", "-k", `gui/${uid}/${PLIST_LABEL}`], {
273
+ stdio: "ignore",
274
+ })
207
275
  return { platform: "darwin", path: dest, label: PLIST_LABEL, bin: hubAgentBin }
208
276
  }
209
277
 
@@ -300,4 +368,6 @@ export const _internal = {
300
368
  macPlistPath,
301
369
  linuxUnitPath,
302
370
  repoTemplatesDir,
371
+ waitUntilBootedOut,
372
+ bootstrapWithRetry,
303
373
  }