@cocorograph/hub-agent 0.8.96 → 0.8.98

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.96",
3
+ "version": "0.8.98",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの Claude / Codex チャットと tmux セッションを中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -18,7 +18,7 @@
18
18
  },
19
19
  "scripts": {
20
20
  "start": "node bin/hub-agent.mjs start",
21
- "test": "node --test --test-concurrency=1 test/*.test.mjs",
21
+ "test": "node --import ./test/setup-env.mjs --test --test-concurrency=1 test/*.test.mjs",
22
22
  "check:publish-on-main": "node scripts/check-publish-on-main.mjs",
23
23
  "prepublishOnly": "node scripts/check-publish-on-main.mjs && npm test"
24
24
  },
package/src/main.mjs CHANGED
@@ -50,6 +50,7 @@ import { WsClient } from "./ws-client.mjs"
50
50
  import { DispatchScheduler } from "./dispatch-scheduler.mjs"
51
51
  import { noteRestoreStop, restoreSessions, sendBridgeMessage } from "./session-restore.mjs"
52
52
  import { AsyncTtlCache } from "./async-ttl-cache.mjs"
53
+ import { makePersistentSeenInputIdRegistry } from "./seen-input-id-store.mjs"
53
54
  import { ClaudeStreamBridge } from "./claude-stream-bridge.mjs"
54
55
  import { installClaudeDisplayMask } from "./claude-display-mask.mjs"
55
56
  import { CodexMcpControl } from "./codex-mcp-control.mjs"
@@ -1179,6 +1180,11 @@ export async function startDaemon({ version, claudeSdk } = {}) {
1179
1180
  restartCoordinator?.stop?.()
1180
1181
  claudeBridge?.shutdown?.()
1181
1182
  codexBridge?.shutdown?.()
1183
+ // 受理済み input_id の保存を待ってから exit する(再起動後の再送で二重実行しないため)。
1184
+ const persistence = await waitForClaudeInputPersistence()
1185
+ if (persistence === "timeout") {
1186
+ logger.warn({ signal }, "claude input_id の保存待ちが上限に達したため終了する")
1187
+ }
1182
1188
  client.stop()
1183
1189
  // 0.6.2 fix: watchdog を解放して event loop を抜けられるようにする
1184
1190
  clearInterval(keepaliveTimer)
@@ -2388,9 +2394,34 @@ function makeSeenInputIdRegistry(max = SEEN_INPUT_IDS_MAX) {
2388
2394
  },
2389
2395
  }
2390
2396
  }
2391
- const seenClaudeChatInputIds = makeSeenInputIdRegistry()
2397
+ let seenClaudeChatInputIds = makePersistentSeenInputIdRegistry(SEEN_INPUT_IDS_MAX)
2398
+ // テストでは処理完了後に呼び、プロセス再起動時のメモリ破棄を模擬する。
2399
+ export function resetSeenClaudeChatInputIdsForTest() {
2400
+ seenClaudeChatInputIds = makePersistentSeenInputIdRegistry(SEEN_INPUT_IDS_MAX)
2401
+ }
2392
2402
  const seenCodexChatInputIds = makeSeenInputIdRegistry()
2393
2403
 
2404
+ /**
2405
+ * 通常終了(SIGTERM・安全な再起動)の直前に、処理中の claude.input と受理済み ID の保存を待つ。
2406
+ * 保存前に exit すると、再起動後の同じ input_id の再送が Claude へ再投入されるため。
2407
+ * ディスクや bridge が止まっていても終了を無期限に延ばさないよう timeoutMs で打ち切る
2408
+ * (打ち切り・強制終了では保証しない)。reject しない。
2409
+ */
2410
+ export async function waitForClaudeInputPersistence(timeoutMs = 2_000) {
2411
+ let timer
2412
+ const timeout = new Promise((resolve) => {
2413
+ timer = setTimeout(() => resolve("timeout"), timeoutMs)
2414
+ })
2415
+ const settled = Promise.allSettled([...claudeInflightByInputId.values()])
2416
+ .then(() => seenClaudeChatInputIds.flush())
2417
+ .then(() => "flushed")
2418
+ try {
2419
+ return await Promise.race([settled, timeout])
2420
+ } finally {
2421
+ clearTimeout(timer)
2422
+ }
2423
+ }
2424
+
2394
2425
  // round4 major#1 (T06542): codex.input の **in-flight** dedup 用レジストリ (input_id -> Promise)。
2395
2426
  // `seen` は settle 後にしか登録されないため、frontend の ack watchdog (5s) が bridge の barrier/RPC
2396
2427
  // (turn/start→turn/started は数秒〜) より短く、同一 input_id が settle 前に再送されると bridge.input を
@@ -2440,7 +2471,7 @@ export function handleClaudeChatInput(msg, ctx) {
2440
2471
  }
2441
2472
  return undefined
2442
2473
  }
2443
- if (input_id && seenClaudeChatInputIds.has(input_id)) {
2474
+ if (input_id && seenClaudeChatInputIds.has(input_id, ctx.logger) && !claudeInflightByInputId.has(input_id)) {
2444
2475
  // 再送 (ack 喪失想定)。同じターンを 2 回実行しないよう sendMessage を
2445
2476
  // 呼び直さず ack のみ返す。
2446
2477
  ctx.logger?.info?.({ stream_id, input_id }, "claude.input dedup (ack only)")
@@ -2491,10 +2522,10 @@ export function handleClaudeChatInput(msg, ctx) {
2491
2522
  // 確認前キャンセルを可能にする)。
2492
2523
  input_id,
2493
2524
  })
2494
- .then((result) => {
2525
+ .then(async (result) => {
2495
2526
  if (!input_id) return
2496
2527
  if (result?.ok) {
2497
- seenClaudeChatInputIds.remember(input_id)
2528
+ await seenClaudeChatInputIds.remember(input_id, ctx.logger)
2498
2529
  ctx.client.send({ type: "claude.input.ack", stream_id, input_id })
2499
2530
  } else {
2500
2531
  ctx.client.send({
@@ -0,0 +1,66 @@
1
+ import { readFileSync } from "node:fs"
2
+ import { mkdir, writeFile, rename, unlink } from "node:fs/promises"
3
+ import { randomUUID } from "node:crypto"
4
+ import path from "node:path"
5
+ import { paths } from "./config.mjs"
6
+
7
+ /** Claude の受理済み ID。初回だけ同期読み込みし、受付の同期的な順序を保つ。 */
8
+ export function makePersistentSeenInputIdRegistry(max = 1_000) {
9
+ const seen = new Set()
10
+ let storePath
11
+ let writeChain = Promise.resolve()
12
+
13
+ function trim() {
14
+ while (seen.size > max) seen.delete(seen.values().next().value)
15
+ }
16
+
17
+ function ensureLoaded(logger) {
18
+ if (storePath !== undefined) return
19
+ storePath = process.env.HUB_CLAUDE_SEEN_INPUT_IDS_PATH ||
20
+ path.join(paths.configDir, "claude-seen-input-ids.json")
21
+ try {
22
+ const ids = JSON.parse(readFileSync(storePath, "utf8"))
23
+ if (!Array.isArray(ids)) throw new Error("expected an input_id array")
24
+ if (ids.some((id) => typeof id !== "string")) {
25
+ logger?.warn?.("claude input_id store contains invalid entries")
26
+ }
27
+ for (const id of ids) if (typeof id === "string") seen.add(id)
28
+ trim()
29
+ } catch (err) {
30
+ if (err.code !== "ENOENT") {
31
+ logger?.warn?.({ err: err.message }, "claude input_id store load failed")
32
+ }
33
+ }
34
+ }
35
+
36
+ return {
37
+ has(id, logger) {
38
+ ensureLoaded(logger)
39
+ return seen.has(id)
40
+ },
41
+ remember(id, logger) {
42
+ ensureLoaded(logger)
43
+ seen.add(id)
44
+ trim()
45
+ const snapshot = JSON.stringify([...seen])
46
+ // rename まで直列化し、古い snapshot が新しい記録を消さないようにする。
47
+ writeChain = writeChain.then(async () => {
48
+ const tmp = `${storePath}.${process.pid}.${randomUUID()}.tmp`
49
+ try {
50
+ await mkdir(path.dirname(storePath), { recursive: true, mode: 0o700 })
51
+ await writeFile(tmp, snapshot, { mode: 0o600, flag: "wx" })
52
+ await rename(tmp, storePath)
53
+ } catch (err) {
54
+ logger?.warn?.({ err: err.message }, "claude input_id store write failed")
55
+ } finally {
56
+ await unlink(tmp).catch(() => {})
57
+ }
58
+ })
59
+ return writeChain
60
+ },
61
+ /** 予約済みの保存がすべて終わるまで待つ(失敗は内部で warn 済みなので reject しない)。 */
62
+ flush() {
63
+ return writeChain
64
+ },
65
+ }
66
+ }
@@ -1,3 +1,4 @@
1
+ import os from "node:os"
1
2
  import { monitorEventLoopDelay, performance } from "node:perf_hooks"
2
3
 
3
4
  // performance.now() はプロセス起動を原点とする単調時計。テストでは _setNowForTest で差し替える。
@@ -26,10 +27,19 @@ export function logPhaseTiming(logger, msg, fields, now = defaultNow) {
26
27
  }
27
28
  }
28
29
 
30
+ /**
31
+ * 起動直後の混み具合を 10/30/60 秒時点で 1 行ずつ出す。
32
+ * cpu_*_ms はプロセス起動からの累積 CPU 時間、load_avg_1m は端末全体の 1 分負荷平均。
33
+ * 経過時間に比べて自分の CPU 時間が小さく負荷平均が高ければ、端末上の他プロセスとの取り合い
34
+ * (起動直後の CLI 一斉起動など) を疑う手掛かりになる。ただし負荷平均は過去 1 分の平均で、
35
+ * 起動前の負荷も残るため、これだけで原因を断定しない (I/O 待ちでも自分の CPU 時間は小さい)。
36
+ */
29
37
  export function startEventLoopMetrics(logger, {
30
38
  now = defaultNow,
31
39
  monitor = monitorEventLoopDelay,
32
40
  schedule = setTimeout,
41
+ cpuUsage = () => process.cpuUsage(),
42
+ loadAvg = () => os.loadavg(),
33
43
  } = {}) {
34
44
  const histogram = monitor({ resolution: 20 })
35
45
  histogram.enable()
@@ -41,6 +51,7 @@ export function startEventLoopMetrics(logger, {
41
51
  p50_ms: histogram.percentile(50) / 1e6,
42
52
  p99_ms: histogram.percentile(99) / 1e6,
43
53
  max_ms: histogram.max / 1e6,
54
+ ...cpuFields(cpuUsage, loadAvg),
44
55
  }, "startup event loop delay")
45
56
  } catch {
46
57
  // 計測ログの失敗は無視する
@@ -50,3 +61,20 @@ export function startEventLoopMetrics(logger, {
50
61
  }, Math.max(0, deadline - now())).unref()
51
62
  }
52
63
  }
64
+
65
+ function cpuFields(cpuUsage, loadAvg) {
66
+ const fields = {}
67
+ try {
68
+ const { user, system } = cpuUsage()
69
+ fields.cpu_user_ms = user / 1000
70
+ fields.cpu_system_ms = system / 1000
71
+ } catch {
72
+ // CPU 時間が取れなくても遅延の記録は出す
73
+ }
74
+ try {
75
+ fields.load_avg_1m = loadAvg()[0]
76
+ } catch {
77
+ // 同上
78
+ }
79
+ return fields
80
+ }