@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 +1 -1
- package/src/hub-bundle.mjs +37 -5
- package/src/log-rotation.mjs +150 -0
- package/src/main.mjs +8 -1
package/package.json
CHANGED
package/src/hub-bundle.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
335
|
-
|
|
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
|
-
|
|
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({
|