@cocorograph/hub-agent 0.8.107 → 0.8.109
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/claude-stream-bridge.mjs +81 -20
- package/src/hub-bundle.mjs +37 -5
- package/src/log-rotation.mjs +150 -0
- package/src/main.mjs +14 -2
package/package.json
CHANGED
|
@@ -66,10 +66,13 @@ function siblingEnvFor(cwd) {
|
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
/** browser 切断後、セッションを生かしたまま再接続を待つ idle TTL (ミリ秒)。これを過ぎ、
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
const IDLE_DETACH_TTL_MS =
|
|
69
|
+
* かつ実作業が無ければ撤去する。2026-10-06 の実測では使用済み chrome-devtools-mcp が
|
|
70
|
+
* 1 本 0.7〜2.5GB を保持し続け、swap は 26GB に達した。idle 中もメモリが小さいとは
|
|
71
|
+
* 限らないため、再接続の猶予を 3 時間に抑える。実作業中は TTL 後も再チェックする。 */
|
|
72
|
+
export const IDLE_DETACH_TTL_MS = readPositiveIntEnv("HUB_AGENT_CLAUDE_IDLE_DETACH_TTL_MS", 3 * 60 * 60 * 1000)
|
|
73
|
+
|
|
74
|
+
/** 無操作かつ実作業無しが 6 時間続いたら、セッションを残して常駐 query だけ畳む。 */
|
|
75
|
+
export const IDLE_ATTACHED_TTL_MS = readPositiveIntEnv("HUB_AGENT_CLAUDE_IDLE_ATTACHED_TTL_MS", 6 * 60 * 60 * 1000)
|
|
73
76
|
|
|
74
77
|
/** 改修2: チャット常駐query化のフラグ。env HUB_AGENT_CHAT_RESIDENT="0" で無効化し
|
|
75
78
|
* 従来の「1メッセージ=1query」へ即ロールバックできる。デフォルト有効。 */
|
|
@@ -201,7 +204,7 @@ const DEAD_TERMINAL_TTL_MS = readPositiveIntEnv(
|
|
|
201
204
|
|
|
202
205
|
/** 子⑤ (T06544): warm 保持を「各 cwd K 本 × 同時 warm 保持する cwd 数 N(既定 20)」で有界化する
|
|
203
206
|
* 上限(codex-stream-bridge.mjs の WARM_CWD_MAX と対)。claude は従来 per-session の softDetach
|
|
204
|
-
*
|
|
207
|
+
* タイマーだけで件数上限が無かったため、多数の cwd を渡り歩くと warm セッション(= SDK
|
|
205
208
|
* 常駐 query プロセス)が N 個を超えて蓄積し得た。cwd 単位の LRU で最古 cwd から早期 reap する。
|
|
206
209
|
* env HUB_AGENT_WARM_CWD_MAX で調整可能(codex と共有)。 */
|
|
207
210
|
export const WARM_CWD_MAX = readPositiveIntEnv("HUB_AGENT_WARM_CWD_MAX", 20)
|
|
@@ -483,6 +486,8 @@ export class ClaudeStreamSession {
|
|
|
483
486
|
* recency を失う。これは touch() で更新し detach でリセットしないので、warm 化しても「最後に
|
|
484
487
|
* 使った時刻」が残り、最古 cwd から LRU reap できる。 */
|
|
485
488
|
this._lastActivityMs = Date.now()
|
|
489
|
+
// 長いターンの終了直後に、入力時刻だけを見て idle 撤去しないための終了時刻。
|
|
490
|
+
this._lastTurnSettledMs = 0
|
|
486
491
|
/** 子⑤ round5 Finding 1: 生成順の単調増加シーケンス(warm 境界のタイブレーク用。値が大きい =
|
|
487
492
|
* 新しい)。recency 同値時に per-cwd=K が新しいセッションを残せるようにする。
|
|
488
493
|
* ⚠️ カウンタは **claude / codex 共通**(session-seq.mjs)。総 live 上限 M は両 CLI を合算して
|
|
@@ -702,6 +707,8 @@ export class ClaudeStreamSession {
|
|
|
702
707
|
* close()/shutdown() で子プロセスが残留する。per-message と同じく AbortController を
|
|
703
708
|
* options へ渡し、interrupt が reject したときと close() のときに最終手段として abort する。 */
|
|
704
709
|
this._residentAbortController = null
|
|
710
|
+
this._foldedIdle = false
|
|
711
|
+
this._idleFoldAbortController = null
|
|
705
712
|
|
|
706
713
|
/** jsonl ライブ追従 watcher (0.6.5)。外部 (tmux 等) の追記を拾う。 */
|
|
707
714
|
this._watcher = null
|
|
@@ -1338,26 +1345,33 @@ export class ClaudeStreamSession {
|
|
|
1338
1345
|
softDetach(ttlMs, onTimeout) {
|
|
1339
1346
|
this._detached = true
|
|
1340
1347
|
if (this._idleTimer) clearTimeout(this._idleTimer)
|
|
1348
|
+
const detachedAt = Date.now()
|
|
1349
|
+
const schedule = (remaining) => {
|
|
1350
|
+
// Node.js のタイマー上限を超える TTL も、期限まで分割して待つ。
|
|
1351
|
+
this._idleTimer = setTimeout(tick, Math.min(remaining, 2147483647))
|
|
1352
|
+
this._idleTimer.unref?.()
|
|
1353
|
+
}
|
|
1341
1354
|
const tick = () => {
|
|
1342
1355
|
// 再アタッチ済みなら撤去しない (reattach が _detached=false にする)
|
|
1343
|
-
if (!this._detached) {
|
|
1356
|
+
if (!this._detached || this._closed) {
|
|
1344
1357
|
this._idleTimer = null
|
|
1345
1358
|
return
|
|
1346
1359
|
}
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1360
|
+
const now = Date.now()
|
|
1361
|
+
const remaining = Math.max(detachedAt, this._lastTurnSettledMs) + ttlMs - now
|
|
1362
|
+
if (remaining > 0) {
|
|
1363
|
+
schedule(remaining)
|
|
1364
|
+
return
|
|
1365
|
+
}
|
|
1366
|
+
// 実作業中は再チェックするが、detach / ターン終了由来の期限自体は延ばさない。
|
|
1367
|
+
if (this._busy || this._pendingMessages.length > 0 || this._hasLiveTask?.(now)) {
|
|
1368
|
+
schedule(ttlMs)
|
|
1354
1369
|
return
|
|
1355
1370
|
}
|
|
1356
1371
|
this._idleTimer = null
|
|
1357
1372
|
onTimeout?.()
|
|
1358
1373
|
}
|
|
1359
|
-
|
|
1360
|
-
this._idleTimer.unref?.()
|
|
1374
|
+
schedule(ttlMs)
|
|
1361
1375
|
}
|
|
1362
1376
|
|
|
1363
1377
|
/**
|
|
@@ -1420,7 +1434,8 @@ export class ClaudeStreamSession {
|
|
|
1420
1434
|
// pending へ退避し、現ターンの result 後に 1 件ずつ drain する。前ターン未完了のまま
|
|
1421
1435
|
// 次を push すると SDK streaming-input で割り込み扱いになり得るため (公式 interrupt 警告)。
|
|
1422
1436
|
// pending は queue_state で UI (送信待ちチップ) に出す (per-message と同じ体験)。
|
|
1423
|
-
if (this._busy) {
|
|
1437
|
+
if (this._busy || (this._foldedIdle && this._residentQuery)) {
|
|
1438
|
+
// fold の abort が完了するまでの入力は finally の既存 resume/drain 経路へ渡す。
|
|
1424
1439
|
this._enqueuePending(prompt, ultracode, inputId)
|
|
1425
1440
|
this.logger?.info(
|
|
1426
1441
|
{ stream_id: this.stream_id, queued: this._pendingMessages.length },
|
|
@@ -1629,6 +1644,7 @@ export class ClaudeStreamSession {
|
|
|
1629
1644
|
/* ignore */
|
|
1630
1645
|
}
|
|
1631
1646
|
}
|
|
1647
|
+
this._lastTurnSettledMs = Date.now()
|
|
1632
1648
|
this._busy = false
|
|
1633
1649
|
if (aborted) {
|
|
1634
1650
|
this.logger?.info({ stream_id: this.stream_id }, "claude turn aborted")
|
|
@@ -2157,6 +2173,7 @@ export class ClaudeStreamSession {
|
|
|
2157
2173
|
// 中断直後の 1 ターンだけを帰属不明として扱い、それが確定したら次からは信頼に戻す。
|
|
2158
2174
|
if (this._lastTurnTexts?.startedUncertain) this._turnTextsUncertain = false
|
|
2159
2175
|
denyPending?.("turn ended")
|
|
2176
|
+
this._lastTurnSettledMs = Date.now()
|
|
2160
2177
|
this._busy = false
|
|
2161
2178
|
// result の後に同一ターンの idle が遅れて届く場合がある。pending drain は同期的に
|
|
2162
2179
|
// 次ターンの busy を立てるため、その idle を新ターンへ誤帰属させないよう先にガードする。
|
|
@@ -2180,6 +2197,9 @@ export class ClaudeStreamSession {
|
|
|
2180
2197
|
* (await するため async 化。idle ハンドラからは fire-and-forget で呼ばれる)。 */
|
|
2181
2198
|
async _drainResidentPending() {
|
|
2182
2199
|
if (this._closed) return
|
|
2200
|
+
// fold 中の旧 query には渡さず、終了時の finally が resume して pending を拾う。
|
|
2201
|
+
// 通常の interrupt 後の drain は従来どおり進める。
|
|
2202
|
+
if (this._foldedIdle && this._residentQuery) return
|
|
2183
2203
|
if (this._pendingMessages.length === 0) {
|
|
2184
2204
|
// 症状1 自己修復 (2026-07-21): _drainPending と同旨。落ちた drain broadcast で送信待ち
|
|
2185
2205
|
// チップが stale 残留するのを、ターン settle 毎の force 再表明で補正する (空 = count=0)。
|
|
@@ -2210,6 +2230,7 @@ export class ClaudeStreamSession {
|
|
|
2210
2230
|
this._emitQueueState([next.text], { startedIds: [next.id] })
|
|
2211
2231
|
else this._emitQueueState([], { force: true })
|
|
2212
2232
|
} catch (err) {
|
|
2233
|
+
this._lastTurnSettledMs = Date.now()
|
|
2213
2234
|
this._busy = false
|
|
2214
2235
|
this._pendingMessages.unshift(next)
|
|
2215
2236
|
this.logger?.warn(
|
|
@@ -2286,6 +2307,7 @@ export class ClaudeStreamSession {
|
|
|
2286
2307
|
try {
|
|
2287
2308
|
const q = this.sdk.query({ prompt: this._inputQueue, options })
|
|
2288
2309
|
this._residentQuery = q
|
|
2310
|
+
this._foldedIdle = false
|
|
2289
2311
|
for await (const msg of q) {
|
|
2290
2312
|
let suppressEmit = false
|
|
2291
2313
|
this._observeTurnTexts(msg)
|
|
@@ -2369,7 +2391,14 @@ export class ClaudeStreamSession {
|
|
|
2369
2391
|
}
|
|
2370
2392
|
}
|
|
2371
2393
|
} catch (err) {
|
|
2372
|
-
|
|
2394
|
+
// run 固有の controller と照合する。fold フラグだけでは、再開後のエラーや
|
|
2395
|
+
// abort と同時に起きた無関係なエラーまで握り潰してしまう。
|
|
2396
|
+
const idleFoldAbort = this._idleFoldAbortController === myAbort && myAbort.signal.aborted && (
|
|
2397
|
+
err?.name === "AbortError" || err?.code === "ABORT_ERR" ||
|
|
2398
|
+
// SDK の abort 終了エラーは Error 派生で name が AbortError とは限らない。
|
|
2399
|
+
err?.message === "Claude Code process aborted by user" || err?.message === "Operation aborted"
|
|
2400
|
+
)
|
|
2401
|
+
if (!this._closed && !idleFoldAbort) {
|
|
2373
2402
|
try {
|
|
2374
2403
|
this.onError?.(err)
|
|
2375
2404
|
} catch {
|
|
@@ -2383,6 +2412,7 @@ export class ClaudeStreamSession {
|
|
|
2383
2412
|
// generator になっていたことを検知できるようにするため。
|
|
2384
2413
|
this._residentQueryReturned = true
|
|
2385
2414
|
this._residentQuery = null
|
|
2415
|
+
if (this._busy) this._lastTurnSettledMs = Date.now()
|
|
2386
2416
|
this._busy = false
|
|
2387
2417
|
denyPending("closed")
|
|
2388
2418
|
// 常駐 query の終了。detached 後にここへ来たら reap する。
|
|
@@ -2503,6 +2533,7 @@ export class ClaudeStreamSession {
|
|
|
2503
2533
|
// いたが、中断ターンの in-flight イベントが _turnProgressed を立ててしまうため成立して
|
|
2504
2534
|
// いなかった)。
|
|
2505
2535
|
this._turnSettled = true
|
|
2536
|
+
if (this._busy) this._lastTurnSettledMs = Date.now()
|
|
2506
2537
|
this._busy = false
|
|
2507
2538
|
this._armStaleIdleGuard()
|
|
2508
2539
|
// 孤児カード防止 (2026-07-10 監査): 中断したターンに紐づく未応答 permission は
|
|
@@ -2586,9 +2617,25 @@ export class ClaudeStreamSession {
|
|
|
2586
2617
|
}
|
|
2587
2618
|
}
|
|
2588
2619
|
|
|
2620
|
+
/** idle の常駐プロセスだけを畳み、再接続と次入力用にセッションを保持する。 */
|
|
2621
|
+
foldIdleResident() {
|
|
2622
|
+
if (this._closed || !this._residentQuery || this._foldedIdle) return false
|
|
2623
|
+
this._foldedIdle = true
|
|
2624
|
+
// 既に別の理由で abort された run を idle fold のものとして扱わない。
|
|
2625
|
+
if (!this._residentAbortController?.signal.aborted) {
|
|
2626
|
+
this._idleFoldAbortController = this._residentAbortController
|
|
2627
|
+
}
|
|
2628
|
+
this._hardAbortResident()
|
|
2629
|
+
this.logger?.info(
|
|
2630
|
+
{ stream_id: this.stream_id, session_id: this.sessionId, cwd: this.cwd, reason: "inactive" },
|
|
2631
|
+
"claude idle resident query folded",
|
|
2632
|
+
)
|
|
2633
|
+
return true
|
|
2634
|
+
}
|
|
2635
|
+
|
|
2589
2636
|
/** SDK-P0-4 (2026-07-13): 常駐 query の最終手段の強制終了。interrupt() (control request) が
|
|
2590
|
-
* 失敗・無応答のときと close() から呼ぶ。AbortController を落とすと generator
|
|
2591
|
-
*
|
|
2637
|
+
* 失敗・無応答のときと close() / idle fold から呼ぶ。AbortController を落とすと generator が
|
|
2638
|
+
* 終了し、finally が pending を resume 付きで再起動して取りこぼしを防ぐ。 */
|
|
2592
2639
|
_hardAbortResident() {
|
|
2593
2640
|
const ac = this._residentAbortController
|
|
2594
2641
|
if (!ac || ac.signal?.aborted) return
|
|
@@ -2632,6 +2679,8 @@ export class ClaudeStreamSession {
|
|
|
2632
2679
|
* idle セッション (_residentQueryReturned=false) は生存中なので reattach 可。 */
|
|
2633
2680
|
_isReattachable() {
|
|
2634
2681
|
if (this._closed) return false
|
|
2682
|
+
// 意図的に畳んだ常駐 query は、同じセッションで次入力から再開できる。
|
|
2683
|
+
if (this._foldedIdle) return true
|
|
2635
2684
|
if (this._residentQueryReturned && !this._residentQuery) return false
|
|
2636
2685
|
return true
|
|
2637
2686
|
}
|
|
@@ -2803,7 +2852,7 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
2803
2852
|
*
|
|
2804
2853
|
* TUI モードに切り替えた session は tmux ペインの対話 claude が write を担うため、
|
|
2805
2854
|
* SDK セッションを生かしたままだと同一 cwd の jsonl を二重ホストして競合する
|
|
2806
|
-
* (DESIGN.md C7)。soft-detach (
|
|
2855
|
+
* (DESIGN.md C7)。soft-detach (既定 3 時間 resident) では止まらないので、ここで明示 close する。
|
|
2807
2856
|
*
|
|
2808
2857
|
* ⚠️ `reason` は exit イベントにそのまま載る。既定の `tui-bind-stop` は上記 TUI bind 用の
|
|
2809
2858
|
* 名前なので、**TUI 以外の理由で止めるときは必ず渡す**こと (既定のままだと、存在しない
|
|
@@ -4202,6 +4251,18 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
4202
4251
|
return !!session._hasLiveTask?.(now)
|
|
4203
4252
|
}
|
|
4204
4253
|
|
|
4254
|
+
/** 購読数に関係なく、実作業が無く無操作 TTL を過ぎた常駐 query だけを畳む。 */
|
|
4255
|
+
foldInactiveSessions(ttlMs = IDLE_ATTACHED_TTL_MS, now = Date.now()) {
|
|
4256
|
+
let folded = 0
|
|
4257
|
+
for (const { session } of this._liveCandidatesForCwd()) {
|
|
4258
|
+
if (!session._residentQuery || this._hasSessionWork(session, now)) continue
|
|
4259
|
+
const lastActive = Math.max(session._lastActivityMs ?? 0, session._lastTurnSettledMs ?? 0)
|
|
4260
|
+
if (now - lastActive < ttlMs) continue
|
|
4261
|
+
if (session.foldIdleResident()) folded += 1
|
|
4262
|
+
}
|
|
4263
|
+
return folded
|
|
4264
|
+
}
|
|
4265
|
+
|
|
4205
4266
|
/**
|
|
4206
4267
|
* daemon の deferred restart が待つべき実作業があるか。
|
|
4207
4268
|
* 多端末 subscribe は warm 保持には必要だが作業ではないため、ここでは数えない。
|
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({
|
|
@@ -2152,10 +2159,15 @@ export function startStateLoop({ client, logger, intervalMs, claudeBridge, codex
|
|
|
2152
2159
|
} catch (err) {
|
|
2153
2160
|
logger?.warn({ err: err?.message }, "gcDeadTerminals failed")
|
|
2154
2161
|
}
|
|
2162
|
+
try {
|
|
2163
|
+
claudeBridge?.foldInactiveSessions?.()
|
|
2164
|
+
} catch (err) {
|
|
2165
|
+
logger?.warn({ err: err?.message }, "claude foldInactiveSessions failed")
|
|
2166
|
+
}
|
|
2155
2167
|
// 子⑤ (T06544): claude 側 warm 境界(per-cwd=K / N-cwd LRU)。K は現行の既定 3
|
|
2156
2168
|
// (**旧仕様**は per-cwd=1 で「各 cwd 1 本」だった。1 cwd で複数会話を同時に生かすため
|
|
2157
2169
|
// 一般化し、総量は下の総 live 上限 M で押さえる)。従来 claude は per-session の softDetach
|
|
2158
|
-
//
|
|
2170
|
+
// タイマーだけで件数上限が無かったため、多数の cwd を渡り歩くと warm な
|
|
2159
2171
|
// SDK 常駐 query が蓄積し得た。codex の reconcileSessions と対で cwd 単位に有界化する。
|
|
2160
2172
|
try {
|
|
2161
2173
|
claudeBridge?.reconcileWarmBounds?.()
|