@cocorograph/hub-agent 0.7.65 → 0.7.67

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.7.65",
3
+ "version": "0.7.67",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -240,23 +240,23 @@ function Set-Systemd($distro) {
240
240
  # Register a logon task that keeps the distro ALIVE (so its systemd --user
241
241
  # hub-agent stays online).
242
242
  #
243
- # 旧実装は `wsl ... /bin/true` で distro を一瞬起こすだけだった。これだと WSL2 の
244
- # アイドルシャットダウンが数十秒で distro ごと畳んでしまい (agent.log に
245
- # `SIGTERM shutting down` が出て `wsl -l -v` が Stopped になる)、hub-agent も停止して
246
- # オフラインに戻る。systemd --user + linger は「distro が起動している間」しか効かない
247
- # ため、distro 自体を起こし続ける常駐プロセスが必要。
243
+ # The old impl used `wsl ... /bin/true` to wake the distro only briefly. WSL2's
244
+ # idle shutdown then collapsed the whole distro within tens of seconds (agent.log
245
+ # shows `SIGTERM shutting down` and `wsl -l -v` goes Stopped), so hub-agent also
246
+ # stopped and went offline. systemd --user + linger only works "while the distro
247
+ # is running", so we need a resident process that keeps the distro awake.
248
248
  #
249
- # 対策: ログオン中ずっと `sleep infinity` を WSL 内で走らせて distro を起動状態に保つ。
250
- # 併せて hub-agent.service を start しておく (enable + linger 済みなら冪等)。
249
+ # Fix: run `sleep infinity` inside WSL for the whole logon session to keep the
250
+ # distro up. Also start hub-agent.service (idempotent when already enabled+linger).
251
251
  function Register-BootTask($distro, $user) {
252
252
  Write-Step "Registering autostart on logon (Task Scheduler, keep-alive)"
253
- # distro を起動し続ける keep-alive。systemctl start は冪等 (既に active なら no-op)。
253
+ # keep-alive that holds the distro up. systemctl start is idempotent (no-op if active).
254
254
  $keepalive = "systemctl --user start hub-agent.service 2>/dev/null; exec sleep infinity"
255
255
  $action = New-ScheduledTaskAction -Execute "wsl.exe" `
256
256
  -Argument "-d $distro -u $user -- bash -lc `"$keepalive`""
257
257
  $trigger = New-ScheduledTaskTrigger -AtLogOn
258
- # ExecutionTimeLimit=0 で無制限 (既定 72h で停止されると distro が落ちる)。
259
- # keep-alive が万一終了したら 1 分後に最大 3 回まで再起動。多重起動は抑止。
258
+ # ExecutionTimeLimit=0 for unlimited (default 72h would stop it and drop the distro).
259
+ # If the keep-alive ever exits, restart after 1 min up to 3 times. Multiple instances suppressed.
260
260
  $set = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries `
261
261
  -StartWhenAvailable -MultipleInstances IgnoreNew `
262
262
  -RestartInterval (New-TimeSpan -Minutes 1) -RestartCount 3 `
@@ -333,14 +333,14 @@ function Main {
333
333
  # --- autostart ---
334
334
  Register-BootTask $distro $user
335
335
 
336
- # セットアップ完了時点でオンライン化まで完結させる: keep-alive タスクを今すぐ起動し、
337
- # distro を起動状態に保つ (再ログオンを待たずに hub-agent が常駐する)。
336
+ # Finish onlining at setup time: start the keep-alive task now and keep the
337
+ # distro up (hub-agent goes resident without waiting for re-logon).
338
338
  Write-Step "Starting keep-alive now (online without re-logon)"
339
339
  try {
340
340
  Start-ScheduledTask -TaskName $TASK_NAME
341
341
  Write-Ok "Keep-alive started; hub-agent should come online shortly"
342
342
  } catch {
343
- Write-Warn "Start-ScheduledTask failed ($($_.Exception.Message)); ログオンし直すと起動します"
343
+ Write-Warn "Start-ScheduledTask failed ($($_.Exception.Message)); re-logon to start it"
344
344
  }
345
345
 
346
346
  Write-Host ""
@@ -74,6 +74,12 @@ export class CodexAppServerClient extends EventEmitter {
74
74
  cwd: this.cwd,
75
75
  env: this.env || process.env,
76
76
  stdio: ["pipe", "pipe", "pipe"],
77
+ // detached にして子 (node ランチャー codex.js) を新しいプロセスグループの leader に
78
+ // する。こうすると stop() で `process.kill(-pid)` によりグループごと落とせ、node
79
+ // ランチャーが spawn する native codex バイナリ (孫) まで確実に終了できる。detached
80
+ // 単体では親終了時に孫が生き残るが、stop()/shutdown() で必ずグループ kill するため
81
+ // orphan 化しない (逆に child.kill() 単体だと node だけ落ちて native が orphan 化する)。
82
+ detached: true,
77
83
  })
78
84
  this.child.stdout.on("data", (chunk) => this._onStdout(chunk))
79
85
  this.child.stderr.on("data", (chunk) => {
@@ -224,15 +230,32 @@ export class CodexAppServerClient extends EventEmitter {
224
230
  if (!this.child || this.closed) return
225
231
  this.closed = true
226
232
  this._settleAllPending(new Error("codex app-server client stopped"))
233
+ const child = this.child
227
234
  try {
228
- this.child.stdin.end()
235
+ child.stdin.end()
229
236
  } catch {
230
237
  // stdin が既に閉じている等は無視して良い (stop の冪等性を優先)。
231
238
  }
232
- try {
233
- this.child.kill()
234
- } catch {
235
- // プロセスが既に無い場合等は無視して良い。
239
+ // detached spawn によりプロセスグループ leader なので、グループごと (孫の native
240
+ // codex バイナリ含む) 落とす。グループが無い/権限エラー時は単体 kill にフォールバック。
241
+ const killGroup = (signal) => {
242
+ try {
243
+ if (typeof child.pid === "number") {
244
+ process.kill(-child.pid, signal)
245
+ return
246
+ }
247
+ } catch {
248
+ // グループが既に消えている / 未 detached 等 → 単体 kill にフォールバック。
249
+ }
250
+ try {
251
+ child.kill(signal)
252
+ } catch {
253
+ // プロセスが既に無い場合等は無視して良い。
254
+ }
236
255
  }
256
+ killGroup("SIGTERM")
257
+ // SIGTERM で落ちない (ハング等) 場合に備え、猶予後 SIGKILL でグループごと強制終了。
258
+ const t = setTimeout(() => killGroup("SIGKILL"), 3000)
259
+ t.unref?.()
237
260
  }
238
261
  }
@@ -64,6 +64,16 @@ function siblingEnvFor(cwd) {
64
64
  // 関わらず完走まで絶対に落とさない (_maybeReapAfterIdle が busy 中は予約しない)。
65
65
  const IDLE_DETACH_TTL_MS = 5 * 60 * 1000
66
66
 
67
+ // 放棄セッションの reaper 閾値。claude-stream-bridge.mjs の gcDeadTerminals と同じ思想・
68
+ // 同じ env (HUB_AGENT_DEAD_TERMINAL_TTL_MS、既定 1 時間)。detach を送らず消えた端末や、
69
+ // codex エラーで turn/completed が来ず activeTurnId が固着したスタックセッションは、
70
+ // detach→TTL(5分) の graceful reap 経路に乗らないため app-server が永久に残る (リーク)。
71
+ // これを補い、「一定時間まったく通信 (browser 入力 / app-server 通知) が無い」セッションを
72
+ // activeTurnId の有無に関わらず定期回収する。進行中の正常なターンは app-server 通知で
73
+ // 都度 touch されるため回収されない (通知が途絶えたスタックターンのみ回収される)。
74
+ const ABANDONED_SESSION_TTL_MS =
75
+ Number(process.env.HUB_AGENT_DEAD_TERMINAL_TTL_MS) || 60 * 60 * 1000
76
+
67
77
  const CLIENT_INFO = { name: "cockpit-hub-agent", title: "Cockpit", version: "1.0.0" }
68
78
 
69
79
  // Slice 6: thread/resume が返す Thread.turns[].items[] を単一の時系列配列へ平坦化する。
@@ -191,6 +201,9 @@ export class CodexStreamBridge extends EventEmitter {
191
201
  pendingApprovals: new Map(), // request_id -> { turnId }
192
202
  detachTimer: null,
193
203
  dead: false,
204
+ // 放棄セッション reaper (gcAbandonedSessions) 用の最終活性時刻。browser 入力と
205
+ // app-server 通知 (ターン進捗) の両方で更新する。Date.now() は hub-agent 実行環境で利用可。
206
+ lastActivityMs: Date.now(),
194
207
  }
195
208
  this.sessions.set(stream_id, session)
196
209
  this._wireClient(session)
@@ -284,8 +297,17 @@ export class CodexStreamBridge extends EventEmitter {
284
297
  })
285
298
  }
286
299
 
300
+ /** gcAbandonedSessions 用の最終活性更新。browser 入力 / app-server 通知の両方で呼ぶ。 */
301
+ _touch(session) {
302
+ if (session) session.lastActivityMs = Date.now()
303
+ }
304
+
287
305
  _onNotification(session, msg) {
288
306
  const { stream_id } = session
307
+ // app-server からの通知 = ターンが進行している証跡。これで touch することで、
308
+ // 正常に走っているターンは reaper に回収されず、通知が途絶えたスタックターンだけが
309
+ // 放棄セッションとして回収される。
310
+ this._touch(session)
289
311
  switch (msg.method) {
290
312
  case "turn/started":
291
313
  session.activeTurnId = msg.params?.turn?.id || null
@@ -361,6 +383,7 @@ export class CodexStreamBridge extends EventEmitter {
361
383
  */
362
384
  async input({ stream_id, message, model, effort }) {
363
385
  const session = this._require(stream_id)
386
+ this._touch(session)
364
387
  const input = userInputFromMessage(message)
365
388
  if (session.activeTurnId) {
366
389
  try {
@@ -408,6 +431,7 @@ export class CodexStreamBridge extends EventEmitter {
408
431
  /** @returns {Promise<void>} */
409
432
  async interrupt({ stream_id }) {
410
433
  const session = this._require(stream_id)
434
+ this._touch(session)
411
435
  if (!session.activeTurnId) return
412
436
  await session.client.request("turn/interrupt", {
413
437
  threadId: session.threadId,
@@ -418,6 +442,7 @@ export class CodexStreamBridge extends EventEmitter {
418
442
  /** サーバー発の承認/入力要求への応答。result の shape は method ごとに呼び出し側が組み立てる。 */
419
443
  permissionReply({ stream_id, request_id, result }) {
420
444
  const session = this._require(stream_id)
445
+ this._touch(session)
421
446
  if (!session.pendingApprovals.has(request_id)) {
422
447
  this.logger?.warn?.({ stream_id, request_id }, "codex permissionReply: unknown request_id (ignored)")
423
448
  return
@@ -508,6 +533,49 @@ export class CodexStreamBridge extends EventEmitter {
508
533
  return session
509
534
  }
510
535
 
536
+ /**
537
+ * 放棄セッションの reaper。claude-stream-bridge.mjs の gcDeadTerminals と同じ思想:
538
+ * 一定時間まったく通信 (browser 入力 / app-server 通知) が無いセッションの app-server を
539
+ * 停止して回収する。detach を送らず消えた端末や、codex エラーで turn/completed が来ず
540
+ * activeTurnId が固着したスタックターンは detach→TTL の graceful reap 経路に乗らないため、
541
+ * これが唯一の回収経路になる (プロセスリーク / 生成中固着の根治)。
542
+ *
543
+ * 進行中の正常なターンは app-server 通知で都度 touch されるので回収されない。
544
+ * main.mjs の state loop から claudeBridge.gcDeadTerminals と並べて定期実行する想定。
545
+ *
546
+ * @param {number} [ttlMs] 無通信とみなす閾値 (テスト用に上書き可)。
547
+ * @returns {number} 回収したセッション数。
548
+ */
549
+ gcAbandonedSessions(ttlMs = ABANDONED_SESSION_TTL_MS) {
550
+ const now = Date.now()
551
+ let reaped = 0
552
+ for (const session of Array.from(this.sessions.values())) {
553
+ if (session.dead) continue
554
+ const last = session.lastActivityMs ?? 0
555
+ if (now - last < ttlMs) continue
556
+ this._clearDetachTimer(session)
557
+ session.dead = true
558
+ this.sessions.delete(session.stream_id)
559
+ try {
560
+ session.client.stop()
561
+ } catch (err) {
562
+ this.logger?.warn?.({ stream_id: session.stream_id, err: err?.message }, "gcAbandonedSessions: stop threw")
563
+ }
564
+ reaped += 1
565
+ this.logger?.info?.(
566
+ { stream_id: session.stream_id, thread_id: session.threadId, idle_ms: now - last },
567
+ "codex abandoned session reaped (no activity past TTL)",
568
+ )
569
+ this.emit("exit", {
570
+ stream_id: session.stream_id,
571
+ thread_id: session.threadId,
572
+ code: 0,
573
+ reason: "idle-reaped",
574
+ })
575
+ }
576
+ return reaped
577
+ }
578
+
511
579
  /** 全セッションを即座に終了する (agent shutdown 用。ターン完走は待たない)。 */
512
580
  shutdown() {
513
581
  for (const session of this.sessions.values()) {
package/src/main.mjs CHANGED
@@ -1871,6 +1871,14 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
1871
1871
  } catch (err) {
1872
1872
  logger?.warn({ err: err?.message }, "gcDeadTerminals failed")
1873
1873
  }
1874
+ // Codex 版 (claude の gcDeadTerminals と対): detach を送らず消えた / turn/completed が
1875
+ // 来ず activeTurnId が固着したスタックセッションの app-server を回収する (プロセス
1876
+ // リーク・生成中固着の根治)。進行中の正常ターンは app-server 通知で touch され残る。
1877
+ try {
1878
+ codexBridge?.gcAbandonedSessions?.()
1879
+ } catch (err) {
1880
+ logger?.warn({ err: err?.message }, "gcAbandonedSessions failed")
1881
+ }
1874
1882
  // 実コンテキスト窓サイズ (1M ベータ等) を反映。contextPctFromUsage の分母を
1875
1883
  // 5s ごとに最新化し、ドーナツが 200k 固定で振り切れるのを防ぐ。
1876
1884
  await refreshContextWindow()