@cocorograph/hub-agent 0.8.107 → 0.8.108

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.8.107",
3
+ "version": "0.8.108",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの Claude / Codex チャットと tmux セッションを中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -322,18 +322,50 @@ function _postInstallArgv(cmd, win) {
322
322
  return null
323
323
  }
324
324
 
325
+ // stdout/stderr を継承した子プロセスの数。agent.log の回転はこの子の追記を保護できないため、
326
+ // 0 でない間は回転を延期する (log-rotation.mjs)。
327
+ let inheritedOutputChildren = 0
328
+
329
+ export function hasInheritedOutputChild() {
330
+ return inheritedOutputChildren > 0
331
+ }
332
+
325
333
  function runOnce(argv, { logger } = {}) {
326
334
  return new Promise((resolve, reject) => {
327
335
  const [bin, ...args] = argv
328
- const child = spawn(bin, args, { stdio: "inherit" })
336
+ inheritedOutputChildren++
337
+ let released = false
338
+ const release = () => {
339
+ if (released) return
340
+ released = true
341
+ inheritedOutputChildren--
342
+ }
343
+ let child
344
+ try {
345
+ child = spawn(bin, args, { stdio: "inherit" })
346
+ } catch (err) {
347
+ release()
348
+ throw err
349
+ }
350
+ // spawn 失敗 (ENOENT 等) は exit が来ないことがある。error でも必ず settle させ、
351
+ // bundle 同期を未完了のまま残さない (resolve/reject は最初の 1 回だけ有効)。
352
+ // 診断ログは settle の後に出し、ログ書き込みの失敗 (ENOSPC 等) が settle を妨げないようにする。
353
+ const safeLog = (level, fields, message) => {
354
+ try { logger?.[level]?.(fields, message) } catch {}
355
+ }
356
+ child.once("error", (err) => {
357
+ release()
358
+ reject(err)
359
+ safeLog("warn", { cmd: argv.join(" "), error: err.message }, "hub-bundle post_install failed")
360
+ })
329
361
  child.on("exit", (code) => {
362
+ release()
330
363
  if (code === 0) {
331
- logger?.info({ cmd: argv.join(" ") }, "hub-bundle post_install ok")
332
364
  resolve(code)
365
+ safeLog("info", { cmd: argv.join(" ") }, "hub-bundle post_install ok")
333
366
  } else {
334
- const err = new Error(`post_install command failed (exit ${code}): ${argv.join(" ")}`)
335
- logger?.warn({ cmd: argv.join(" "), code }, "hub-bundle post_install failed")
336
- reject(err)
367
+ reject(new Error(`post_install command failed (exit ${code}): ${argv.join(" ")}`))
368
+ safeLog("warn", { cmd: argv.join(" "), code }, "hub-bundle post_install failed")
337
369
  }
338
370
  })
339
371
  })
@@ -0,0 +1,150 @@
1
+ import { randomBytes } from "node:crypto"
2
+ import { closeSync, fstatSync, ftruncateSync, openSync, readdirSync, readSync, renameSync, statSync, unlinkSync, writeSync } from "node:fs"
3
+ import os from "node:os"
4
+ import path from "node:path"
5
+
6
+ export const DEFAULT_LOG_MAX_BYTES = 50 * 1024 * 1024
7
+ export const LOG_CHECK_INTERVAL_MS = 10 * 60 * 1000
8
+ // 回転は同期 I/O で常駐処理を止めるため、設定値で無制限に大きくさせない。
9
+ export const MAX_LOG_MAX_BYTES = 512 * 1024 * 1024
10
+
11
+ export function resolveLogMaxBytes(value = process.env.HUB_AGENT_LOG_MAX_BYTES) {
12
+ if (typeof value !== "string" || !/^\d+$/.test(value)) return DEFAULT_LOG_MAX_BYTES
13
+ const bytes = Number(value)
14
+ if (!Number.isSafeInteger(bytes) || bytes <= 0) return DEFAULT_LOG_MAX_BYTES
15
+ return Math.min(bytes, MAX_LOG_MAX_BYTES)
16
+ }
17
+
18
+ function sameFile(a, b) {
19
+ return a.dev === b.dev && a.ino === b.ino
20
+ }
21
+
22
+ // 削除判断に使うので、確実に居ないと分かる ESRCH のときだけ死亡扱いにする
23
+ // (hub-bundle.mjs の _pidAlive はロック回収用で、EPERM 以外を死亡扱いにする別契約)。
24
+ function processAlive(pid) {
25
+ try {
26
+ process.kill(pid, 0)
27
+ return true
28
+ } catch (err) {
29
+ return err?.code !== "ESRCH"
30
+ }
31
+ }
32
+
33
+ /** 異常終了で残った一時ファイルを、作成したプロセスが既に居ない場合だけ消す。 */
34
+ function removeStaleTemps(dir, prefix) {
35
+ for (const name of readdirSync(dir)) {
36
+ if (!name.startsWith(prefix)) continue
37
+ const pid = Number(name.slice(prefix.length).split("-")[0])
38
+ if (!Number.isSafeInteger(pid) || pid <= 0 || pid === process.pid || processAlive(pid)) continue
39
+ try { unlinkSync(path.join(dir, name)) } catch {}
40
+ }
41
+ }
42
+
43
+ /**
44
+ * logrotate の copytruncate と同じ方式。fd は launchd が開いたまま使うため rename はしない。
45
+ * 前提: この fd へ書く writer は同期 write だけ (main.mjs の pino は sync destination)。
46
+ * 同期区間の中でサイズ取得・コピー・truncate を行うので、同じプロセスの write とは競合しない。
47
+ * stdout を継承した子プロセスが動いている間は isDeferred で延期する。
48
+ */
49
+ export function rotateLogIfNeeded(logger, {
50
+ fd = 1,
51
+ home = os.homedir(),
52
+ maxBytes = resolveLogMaxBytes(),
53
+ isDeferred = () => false,
54
+ } = {}) {
55
+ try {
56
+ if (isDeferred()) return false
57
+ const before = fstatSync(fd)
58
+ if (!before.isFile()) return false
59
+ const logPath = path.join(home, ".hub", "agent.log")
60
+ let target
61
+ try {
62
+ target = statSync(logPath)
63
+ } catch (err) {
64
+ if (err.code === "ENOENT") return false
65
+ throw err
66
+ }
67
+ if (!sameFile(before, target) || before.size <= maxBytes) return false
68
+
69
+ const source = openSync(logPath, "r")
70
+ try {
71
+ if (!sameFile(before, fstatSync(source))) return false
72
+ // 上限値に比例してメモリを確保せず、末尾だけを小さな塊でコピーする。
73
+ const buffer = Buffer.alloc(64 * 1024)
74
+ let position = before.size - maxBytes
75
+ if (readSync(source, buffer, 0, 1, position - 1) !== 1) {
76
+ throw new Error("log changed before rotation")
77
+ }
78
+ let atLineStart = buffer[0] === 10
79
+ // 旧 .1 はコピー完了まで壊さない。一時ファイルを排他作成して rename で置き換える
80
+ // (unlink はリンク自体を消すだけで参照先を辿らない。wx は既存のリンクや FIFO を開かない)。
81
+ const backupPath = `${logPath}.1`
82
+ const tempPrefix = `${path.basename(backupPath)}.tmp-`
83
+ removeStaleTemps(path.dirname(logPath), tempPrefix)
84
+ const tempPath = path.join(
85
+ path.dirname(logPath),
86
+ `${tempPrefix}${process.pid}-${randomBytes(6).toString("hex")}`,
87
+ )
88
+ const backup = openSync(tempPath, "wx", 0o600)
89
+ let completed = false
90
+ let closeError = null
91
+ try {
92
+ while (position < before.size) {
93
+ const length = readSync(source, buffer, 0, Math.min(buffer.length, before.size - position), position)
94
+ if (length === 0) throw new Error("log changed during rotation")
95
+ position += length
96
+ let offset = 0
97
+ if (!atLineStart) {
98
+ const newline = buffer.subarray(0, length).indexOf(10)
99
+ if (newline === -1) continue
100
+ offset = newline + 1
101
+ atLineStart = true
102
+ }
103
+ while (offset < length) {
104
+ const written = writeSync(backup, buffer, offset, length - offset)
105
+ if (written === 0) throw new Error("log backup write made no progress")
106
+ offset += written
107
+ }
108
+ }
109
+ completed = true
110
+ } finally {
111
+ // close が失敗しても未確定の一時ファイルは消す (稼働中は残骸回収の対象外になるため)。
112
+ try {
113
+ closeSync(backup)
114
+ } catch (err) {
115
+ if (completed) {
116
+ completed = false
117
+ closeError = err
118
+ }
119
+ }
120
+ if (!completed) {
121
+ try { unlinkSync(tempPath) } catch {}
122
+ }
123
+ }
124
+ if (closeError) throw closeError
125
+ try {
126
+ renameSync(tempPath, backupPath)
127
+ } catch (err) {
128
+ try { unlinkSync(tempPath) } catch {}
129
+ throw err
130
+ }
131
+ } finally {
132
+ closeSync(source)
133
+ }
134
+ ftruncateSync(fd, 0)
135
+ logger?.info?.({ previousBytes: before.size, maxBytes }, "agent log rotated")
136
+ return true
137
+ } catch (err) {
138
+ // ログ出力自体の失敗も agent を停止させない。
139
+ try { logger?.warn?.({ error: err.message }, "agent log rotation failed") } catch {}
140
+ return false
141
+ }
142
+ }
143
+
144
+ export function startLogRotation(logger, { schedule = setInterval, ...options } = {}) {
145
+ const check = () => rotateLogIfNeeded(logger, options)
146
+ check()
147
+ const timer = schedule(check, LOG_CHECK_INTERVAL_MS)
148
+ timer.unref()
149
+ return timer
150
+ }
package/src/main.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  import { performance } from "node:perf_hooks"
2
2
  import { createHash } from "node:crypto"
3
3
  import { logPhaseTiming, startEventLoopMetrics } from "./startup-metrics.mjs"
4
+ import { startLogRotation } from "./log-rotation.mjs"
4
5
  /**
5
6
  * hub-agent daemon 本体。
6
7
  *
@@ -37,6 +38,7 @@ import {
37
38
  import {
38
39
  codexTerminalSkipVersion,
39
40
  compareBundleVersions,
41
+ hasInheritedOutputChild,
40
42
  readCliBundleVersionSync,
41
43
  readCliInstalledVersionSync,
42
44
  readCodexApprovalStatusSync,
@@ -150,7 +152,10 @@ export function codexAvailableForDispatch(ctx) {
150
152
  return (ctx?._resolveCodexBinary || resolveCodexBinary)().available
151
153
  }
152
154
 
153
- const logger = pino({ name: "hub-agent" })
155
+ // 同期書き込みにする。既定の非同期 destination は投入済み write の完了を待つ手段が無く
156
+ // (minLength 0 の flush は即時 callback)、agent.log の copy+truncate 回転の間に完了した
157
+ // write が消えるため。
158
+ const logger = pino({ name: "hub-agent" }, pino.destination({ dest: 1, sync: true }))
154
159
 
155
160
 
156
161
  const BUNDLE_MANIFEST_PATH =
@@ -908,6 +913,7 @@ export function wireBridgeEvents(client, { claudeBridge, codexBridge }) {
908
913
  }
909
914
 
910
915
  export async function startDaemon({ version, claudeSdk } = {}) {
916
+ const logRotationTimer = startLogRotation(logger, { isDeferred: hasInheritedOutputChild })
911
917
  startEventLoopMetrics(logger)
912
918
  const config = await readConfig()
913
919
  if (!config) {
@@ -1190,6 +1196,7 @@ export async function startDaemon({ version, claudeSdk } = {}) {
1190
1196
  client.stop()
1191
1197
  // 0.6.2 fix: watchdog を解放して event loop を抜けられるようにする
1192
1198
  clearInterval(keepaliveTimer)
1199
+ clearInterval(logRotationTimer)
1193
1200
  process.exit(0)
1194
1201
  }
1195
1202
  restartCoordinator = new DeferredRestartCoordinator({