@cocorograph/hub-agent 0.7.67 → 0.7.69
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/bin/hub-agent.mjs +8 -0
- package/package.json +1 -1
- package/src/claude-stream-bridge.mjs +30 -0
- package/src/codex-stream-bridge.mjs +242 -15
- package/src/hub-bundle.mjs +562 -48
- package/src/main.mjs +188 -0
- package/src/sibling-inject.mjs +31 -1
- package/src/ws-client.mjs +29 -2
package/src/main.mjs
CHANGED
|
@@ -20,6 +20,17 @@ import path from "node:path"
|
|
|
20
20
|
import pino from "pino"
|
|
21
21
|
|
|
22
22
|
import { detectAvailableClis, detectPlatform, readConfig, writeConfig } from "./config.mjs"
|
|
23
|
+
import {
|
|
24
|
+
codexTerminalSkipVersion,
|
|
25
|
+
compareBundleVersions,
|
|
26
|
+
readCliBundleVersionSync,
|
|
27
|
+
readCliInstalledVersionSync,
|
|
28
|
+
readCodexApprovalStatusSync,
|
|
29
|
+
readCodexConfigText,
|
|
30
|
+
readHubAiState,
|
|
31
|
+
resolveSyncTargets,
|
|
32
|
+
syncBundleTargets,
|
|
33
|
+
} from "./hub-bundle.mjs"
|
|
23
34
|
import { extractLastAssistantText, pickContextText } from "./extract-paragraph.mjs"
|
|
24
35
|
import { loadPlugins, runHookBroadcast, runHookChain } from "./plugin-loader.mjs"
|
|
25
36
|
import { WsClient } from "./ws-client.mjs"
|
|
@@ -496,6 +507,152 @@ export async function kickSetupHubAiForDetectedClis({
|
|
|
496
507
|
return targets
|
|
497
508
|
}
|
|
498
509
|
|
|
510
|
+
// ── bundle.sync.request(backend server-driven 自動同期)の状態管理 ──────────────
|
|
511
|
+
//
|
|
512
|
+
// backend が heartbeat の `bundle_version` とリモート最新版の差分を検知すると agent へ
|
|
513
|
+
// `bundle.sync.request` を送る。agent はそれを受けて **非対話** で syncBundleTargets を実行する
|
|
514
|
+
// (files/hooks/setup + codex 承認モード補完。codex mcp add / OAuth は呼ばない)。
|
|
515
|
+
//
|
|
516
|
+
// 連続 heartbeat ごとに full bundle を取りに行かないよう、以下のガードを重ねる:
|
|
517
|
+
// - running: 同期中の再入を弾く
|
|
518
|
+
// - debounce: 直近 _BUNDLE_SYNC_MIN_INTERVAL_MS 以内の要求は間引く
|
|
519
|
+
// - version-guard: 要求 version が既に files+承認とも反映済みなら fetch せず skip(冪等・無駄取得回避)
|
|
520
|
+
// - backoff: 失敗が続くほど次回まで待つ(指数、上限あり)
|
|
521
|
+
// - auto_update:false(.hub_ai_state.json)を尊重し自動同期しない
|
|
522
|
+
const _BUNDLE_SYNC_MIN_INTERVAL_MS = 60_000
|
|
523
|
+
const _BUNDLE_SYNC_BACKOFF_BASE_MS = 30_000
|
|
524
|
+
const _BUNDLE_SYNC_BACKOFF_MAX_MS = 10 * 60_000
|
|
525
|
+
const _bundleSyncState = { running: false, lastAttemptAt: 0, lastFailureAt: 0, failureCount: 0 }
|
|
526
|
+
|
|
527
|
+
/** テスト用: 自動同期のガード状態をリセットする。 */
|
|
528
|
+
export function _resetBundleSyncStateForTest() {
|
|
529
|
+
_bundleSyncState.running = false
|
|
530
|
+
_bundleSyncState.lastAttemptAt = 0
|
|
531
|
+
_bundleSyncState.lastFailureAt = 0
|
|
532
|
+
_bundleSyncState.failureCount = 0
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* ある target(cli_kind)について、要求 version が既に反映済みかを判定する。
|
|
537
|
+
* - codex: mcp_approval_applied_version(無ければ installed_version)が要求以上
|
|
538
|
+
* - claude: installed_version が要求以上
|
|
539
|
+
* auto_update:false のときは「対象外(=要求は満たされたものとして扱う=同期しない)」を返す。
|
|
540
|
+
*/
|
|
541
|
+
async function _bundleTargetSatisfied(cliKind, requestedVersion, readStateImpl, readConfigImpl) {
|
|
542
|
+
const st = (await (readStateImpl || readHubAiState)(cliKind)) || {}
|
|
543
|
+
if (st.auto_update === false) return true // opt-out は「同期不要」扱い
|
|
544
|
+
if (!requestedVersion) return false
|
|
545
|
+
const filesVer = st.installed_version || "0.0.0"
|
|
546
|
+
if (compareBundleVersions(filesVer, requestedVersion) < 0) return false
|
|
547
|
+
if (cliKind === "codex") {
|
|
548
|
+
const approvalVer = st.mcp_approval_applied_version || "0.0.0"
|
|
549
|
+
const approved = compareBundleVersions(approvalVer, requestedVersion) >= 0
|
|
550
|
+
// 恒久 skip は heartbeat と同一の共通判定(codexTerminalSkipVersion)で **fingerprint 検証済み**
|
|
551
|
+
// のときだけ有効。config が変わっている / io にリセットされた skip は無効化され、再試行される。
|
|
552
|
+
const configText = await (readConfigImpl || readCodexConfigText)()
|
|
553
|
+
const skipV = codexTerminalSkipVersion(st, configText)
|
|
554
|
+
const terminallySkipped = skipV ? compareBundleVersions(skipV, requestedVersion) >= 0 : false
|
|
555
|
+
if (!approved && !terminallySkipped) return false
|
|
556
|
+
}
|
|
557
|
+
return true
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* `bundle.sync.request` を処理する。非対話 syncBundleTargets を debounce/version-guard/backoff 付きで実行。
|
|
562
|
+
* 主経路(daemon による事前同期)。テスト注入点: syncTargetsImpl / detectImpl / readStateImpl / nowMs。
|
|
563
|
+
*/
|
|
564
|
+
export async function handleBundleSyncRequest(msg, ctx, opts = {}) {
|
|
565
|
+
const logger = ctx?.logger
|
|
566
|
+
const requestedVersion = typeof msg?.version === "string" ? msg.version : null
|
|
567
|
+
const now = typeof opts.nowMs === "number" ? opts.nowMs : Date.now()
|
|
568
|
+
|
|
569
|
+
// ⚠️ 再入防止フラグは **await をまたぐ前に同期的に** claim する。await を挟んでから running を
|
|
570
|
+
// 立てると、fire-and-forget で並んだ 2 本目が running を false のまま通過して二重 sync する
|
|
571
|
+
// (dispatch の bundle.sync.request は直列キューを塞がない fire-and-forget のため実際に競合しうる)。
|
|
572
|
+
if (_bundleSyncState.running) {
|
|
573
|
+
logger?.info?.("bundle.sync.request: 既に同期中のため skip")
|
|
574
|
+
return { skipped: "running" }
|
|
575
|
+
}
|
|
576
|
+
_bundleSyncState.running = true
|
|
577
|
+
try {
|
|
578
|
+
if (now - _bundleSyncState.lastAttemptAt < _BUNDLE_SYNC_MIN_INTERVAL_MS) {
|
|
579
|
+
logger?.debug?.("bundle.sync.request: debounce により skip")
|
|
580
|
+
return { skipped: "debounced" }
|
|
581
|
+
}
|
|
582
|
+
if (_bundleSyncState.failureCount > 0) {
|
|
583
|
+
const backoff = Math.min(
|
|
584
|
+
_BUNDLE_SYNC_BACKOFF_BASE_MS * 2 ** (_bundleSyncState.failureCount - 1),
|
|
585
|
+
_BUNDLE_SYNC_BACKOFF_MAX_MS,
|
|
586
|
+
)
|
|
587
|
+
if (now - _bundleSyncState.lastFailureAt < backoff) {
|
|
588
|
+
logger?.debug?.({ backoff }, "bundle.sync.request: backoff 中のため skip")
|
|
589
|
+
return { skipped: "backoff" }
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const config = ctx?.config
|
|
594
|
+
if (!config?.hub_url || !config?.agent_id || !config?.agent_token) {
|
|
595
|
+
logger?.warn?.("bundle.sync.request: agent 設定が不足しているため skip")
|
|
596
|
+
return { skipped: "no_config" }
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const detect = opts.detectImpl || detectAvailableClis
|
|
600
|
+
const readStateImpl = opts.readStateImpl || readHubAiState
|
|
601
|
+
let targets
|
|
602
|
+
try {
|
|
603
|
+
targets = resolveSyncTargets(await detect())
|
|
604
|
+
} catch {
|
|
605
|
+
targets = ["claude"]
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// version-guard: 全 target が要求 version 反映済みなら fetch せず skip(冪等・無駄取得回避)。
|
|
609
|
+
const satisfied = await Promise.all(
|
|
610
|
+
targets.map((c) => _bundleTargetSatisfied(c, requestedVersion, readStateImpl, opts.readConfigImpl)),
|
|
611
|
+
)
|
|
612
|
+
if (satisfied.every(Boolean)) {
|
|
613
|
+
logger?.info?.({ requestedVersion }, "bundle.sync.request: 既に反映済みのため skip(fetch しない)")
|
|
614
|
+
// 反映済みなら試行時刻だけ進めて過剰 debounce 判定を安定させる(失敗ではないので backoff は触らない)。
|
|
615
|
+
_bundleSyncState.lastAttemptAt = now
|
|
616
|
+
return { skipped: "already_satisfied" }
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
_bundleSyncState.lastAttemptAt = now
|
|
620
|
+
try {
|
|
621
|
+
const syncTargets = opts.syncTargetsImpl || syncBundleTargets
|
|
622
|
+
const { results, failures } = await syncTargets({
|
|
623
|
+
targets,
|
|
624
|
+
hubUrl: config.hub_url,
|
|
625
|
+
agentId: config.agent_id,
|
|
626
|
+
agentToken: config.agent_token,
|
|
627
|
+
logger,
|
|
628
|
+
nonInteractive: true, // ⚠️ 自動同期は完全非対話(codex mcp add / OAuth を呼ばない)
|
|
629
|
+
})
|
|
630
|
+
if (failures.length > 0) {
|
|
631
|
+
_bundleSyncState.failureCount += 1
|
|
632
|
+
_bundleSyncState.lastFailureAt = Date.now()
|
|
633
|
+
} else {
|
|
634
|
+
_bundleSyncState.failureCount = 0
|
|
635
|
+
}
|
|
636
|
+
// 観測用に結果を backend へ返す(次回 heartbeat で bundle_version も更新される)。
|
|
637
|
+
ctx?.client?.send?.({
|
|
638
|
+
type: "bundle.sync.result",
|
|
639
|
+
version: requestedVersion,
|
|
640
|
+
ok: failures.length === 0,
|
|
641
|
+
failures,
|
|
642
|
+
})
|
|
643
|
+
logger?.info?.({ requestedVersion, failures }, "bundle.sync.request 完了")
|
|
644
|
+
return { results, failures }
|
|
645
|
+
} catch (err) {
|
|
646
|
+
_bundleSyncState.failureCount += 1
|
|
647
|
+
_bundleSyncState.lastFailureAt = Date.now()
|
|
648
|
+
logger?.warn?.({ err: err?.message }, "bundle.sync.request 実行に失敗")
|
|
649
|
+
return { error: err?.message || String(err) }
|
|
650
|
+
}
|
|
651
|
+
} finally {
|
|
652
|
+
_bundleSyncState.running = false
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
499
656
|
export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
500
657
|
const config = await readConfig()
|
|
501
658
|
if (!config) {
|
|
@@ -597,6 +754,27 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
597
754
|
logger.info({ bundleVersion }, "hub bundle version detected")
|
|
598
755
|
}
|
|
599
756
|
|
|
757
|
+
// CLI 別(claude / codex)の bundle version + codex 承認反映状況を毎 heartbeat 報告する provider。
|
|
758
|
+
// backend が per-CLI に stale / 承認未反映を判定して bundle.sync.request を送る(Codex-only 環境や
|
|
759
|
+
// 「Claude 最新・Codex 承認未反映」でも正しく検知させるため。~/.claude の manifest だけに依存しない)。
|
|
760
|
+
const bundleStatusProvider = () => {
|
|
761
|
+
const codexApproval = readCodexApprovalStatusSync()
|
|
762
|
+
return {
|
|
763
|
+
// ⚠️ backend の「反映済み」判定に使う version は **setup 完了済み** の installed_version(manifest
|
|
764
|
+
// ではない。manifest は setup 前に配置されるため、setup 失敗を「適用済み」と誤報告してしまう)。
|
|
765
|
+
bundle_versions: {
|
|
766
|
+
claude: readCliInstalledVersionSync("claude"),
|
|
767
|
+
codex: readCliInstalledVersionSync("codex"),
|
|
768
|
+
},
|
|
769
|
+
// 参考: 配置済み(setup 完了とは限らない)manifest version。判定には使わない(観測用)。
|
|
770
|
+
downloaded_versions: {
|
|
771
|
+
claude: readCliBundleVersionSync("claude"),
|
|
772
|
+
codex: readCliBundleVersionSync("codex"),
|
|
773
|
+
},
|
|
774
|
+
codex_approval: codexApproval,
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
600
778
|
const client = new WsClient(config, {
|
|
601
779
|
logger,
|
|
602
780
|
version,
|
|
@@ -604,6 +782,7 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
604
782
|
availableClis,
|
|
605
783
|
bundleVersion,
|
|
606
784
|
bundleVersionProvider: readBundleVersionSync,
|
|
785
|
+
bundleStatusProvider,
|
|
607
786
|
bundleManifestPath: BUNDLE_MANIFEST_PATH,
|
|
608
787
|
})
|
|
609
788
|
|
|
@@ -2757,6 +2936,15 @@ async function dispatch(msg, ctx) {
|
|
|
2757
2936
|
case "error":
|
|
2758
2937
|
ctx.logger.warn({ msg }, "hub error")
|
|
2759
2938
|
return
|
|
2939
|
+
case "bundle.sync.request": {
|
|
2940
|
+
// backend が heartbeat の bundle_version 差分を検知して送る自動同期要求。
|
|
2941
|
+
// 非対話 syncBundleTargets を debounce/version-guard/backoff/lock 付きで実行する
|
|
2942
|
+
// (codex mcp add / OAuth は呼ばない)。fire-and-forget で dispatch を塞がない。
|
|
2943
|
+
handleBundleSyncRequest(msg, ctx).catch((err) => {
|
|
2944
|
+
ctx.logger?.warn?.({ err: err?.message }, "handleBundleSyncRequest threw")
|
|
2945
|
+
})
|
|
2946
|
+
return
|
|
2947
|
+
}
|
|
2760
2948
|
case "agent.shutdown": {
|
|
2761
2949
|
// Cockpit の「エージェント削除 (ゴミ箱)」由来。この端末をエージェントとして
|
|
2762
2950
|
// 撤去する: サービス停止・無効化・autostart 撤去のみ。ユーザーデータ
|
package/src/sibling-inject.mjs
CHANGED
|
@@ -112,9 +112,13 @@ export class SiblingInjectBridge {
|
|
|
112
112
|
const sent = bridge?.sendToCwd?.(cwd, text) ?? false
|
|
113
113
|
result = sent
|
|
114
114
|
? { ok: true }
|
|
115
|
-
: { ok: false, error: "no_live_session_for_cwd" }
|
|
115
|
+
: { ok: false, error: "no_live_session_for_cwd", ...this._diagnose(cwd, target_cli_kind) }
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
+
this.logger?.[result.ok ? "info" : "warn"]?.(
|
|
119
|
+
{ request_id, cwd, target_cli_kind, ...(result.ok ? {} : { error: result.error, live_sessions: result.live_sessions }) },
|
|
120
|
+
result.ok ? "sibling-inject delivered" : "sibling-inject failed",
|
|
121
|
+
)
|
|
118
122
|
await this._writeResult(request_id, result)
|
|
119
123
|
// 要求ファイルは処理後に必ず削除する (成否問わず、再処理させないため)。
|
|
120
124
|
try {
|
|
@@ -124,6 +128,32 @@ export class SiblingInjectBridge {
|
|
|
124
128
|
}
|
|
125
129
|
}
|
|
126
130
|
|
|
131
|
+
/**
|
|
132
|
+
* `no_live_session_for_cwd` に「一時的か恒久的か」「TUI と混同していないか」を
|
|
133
|
+
* 切り分ける材料を添える (2026-07-17: エラー文字列だけでは (a) 1 回失敗 → 再送で成功
|
|
134
|
+
* (b) 3 回失敗 → 本当に消滅 の区別が付かず誤判断した)。
|
|
135
|
+
*
|
|
136
|
+
* ⚠️ ここに出るのは hub-agent が spawn した **チャットセッションのみ**。tmux 配下で
|
|
137
|
+
* 動く TUI codex は ask-sibling の宛先にならないため、一覧が空でも「codex は画面上
|
|
138
|
+
* 動いている」ことは普通に起きる。その旨を hint に明記する。
|
|
139
|
+
* @param {string} cwd
|
|
140
|
+
* @param {string} targetKind
|
|
141
|
+
*/
|
|
142
|
+
_diagnose(cwd, targetKind) {
|
|
143
|
+
const all = [
|
|
144
|
+
...(this.claudeBridge?.describeLiveSessions?.() ?? []),
|
|
145
|
+
...(this.codexBridge?.describeLiveSessions?.() ?? []),
|
|
146
|
+
]
|
|
147
|
+
const sameCwd = all.filter((s) => s.cwd === cwd)
|
|
148
|
+
const hint = sameCwd.length
|
|
149
|
+
? `この cwd には ${sameCwd.map((s) => s.kind).join("/")} のチャットセッションはあるが ${targetKind} は無い。`
|
|
150
|
+
: `この cwd には hub-agent 管理下のチャットセッションが 1 本も無い。` +
|
|
151
|
+
`tmux の TUI codex は ask-sibling の宛先にならない (hub-agent が spawn した` +
|
|
152
|
+
` Cockpit のチャットセッションのみが対象)。Cockpit のブラウザで対象 cwd の` +
|
|
153
|
+
` ${targetKind} チャットを開き直してから再送すること。`
|
|
154
|
+
return { live_sessions: all, hint }
|
|
155
|
+
}
|
|
156
|
+
|
|
127
157
|
/** 結果ファイルを atomic write する (呼び出し側 CLI がポーリングする)。 */
|
|
128
158
|
async _writeResult(request_id, result) {
|
|
129
159
|
const fp = path.join(this.dir, `${request_id}.result.json`)
|
package/src/ws-client.mjs
CHANGED
|
@@ -119,6 +119,12 @@ export class WsClient extends EventEmitter {
|
|
|
119
119
|
typeof opts.bundleVersionProvider === "function"
|
|
120
120
|
? opts.bundleVersionProvider
|
|
121
121
|
: null
|
|
122
|
+
// CLI 別(claude / codex)の bundle version と codex 承認反映状況を返す provider。
|
|
123
|
+
// backend が per-CLI に stale/承認未反映を判定して bundle.sync.request を送るために報告する。
|
|
124
|
+
// 返り値例: { bundle_versions: {claude, codex}, codex_approval: {applied, skipped} }
|
|
125
|
+
this.bundleStatusProvider =
|
|
126
|
+
typeof opts.bundleStatusProvider === "function" ? opts.bundleStatusProvider : null
|
|
127
|
+
this.bundleStatus = {}
|
|
122
128
|
this.bundleManifestPath = opts.bundleManifestPath || null
|
|
123
129
|
this.bundleWatcher = null
|
|
124
130
|
this.bundleWatchDebounceTimer = null
|
|
@@ -230,8 +236,9 @@ export class WsClient extends EventEmitter {
|
|
|
230
236
|
// 同期は backend が承認するため hello 送信後の同期 send_json で問題ない。
|
|
231
237
|
// ただし flush は hello 直後ではなく streams.sync.request の後に置く方が
|
|
232
238
|
// 安全 (sync 完了で stream_id がサーバに復元される想定)。
|
|
233
|
-
// hello 前に最新の bundle version
|
|
239
|
+
// hello 前に最新の bundle version / CLI 別ステータスを読み直す (再接続時の鮮度確保)
|
|
234
240
|
this._refreshBundleVersion()
|
|
241
|
+
this._refreshBundleStatus()
|
|
235
242
|
this._sendJson({
|
|
236
243
|
type: "hello",
|
|
237
244
|
agent_id: this.config.agent_id,
|
|
@@ -240,6 +247,7 @@ export class WsClient extends EventEmitter {
|
|
|
240
247
|
bundle_version: this.bundleVersion,
|
|
241
248
|
platform: this.platform,
|
|
242
249
|
available_clis: this.availableClis,
|
|
250
|
+
...this.bundleStatus,
|
|
243
251
|
})
|
|
244
252
|
// Plan ε: 毎回 reconnect 直後に backend ↔ agent の stream_id を能動同期する
|
|
245
253
|
// (orphan stream の即時 kill 用)。response は main.mjs の dispatch が拾い、
|
|
@@ -446,9 +454,10 @@ export class WsClient extends EventEmitter {
|
|
|
446
454
|
this._forceReconnect()
|
|
447
455
|
return
|
|
448
456
|
}
|
|
449
|
-
// heartbeat 都度 provider 経由で bundle version
|
|
457
|
+
// heartbeat 都度 provider 経由で bundle version / CLI 別ステータスを最新化する。
|
|
450
458
|
// fs.watch を取り逃した変更 (atomic rename / 別マウント越し等) への保険。
|
|
451
459
|
this._refreshBundleVersion()
|
|
460
|
+
this._refreshBundleStatus()
|
|
452
461
|
// 送信失敗 (= ws not OPEN) を検知したら即時 close + reconnect 発火。
|
|
453
462
|
// setInterval を放置すると死んだコネクションに heartbeat を投げ続けて
|
|
454
463
|
// 30s 間気付かないので、close を強制トリガーする。
|
|
@@ -456,6 +465,7 @@ export class WsClient extends EventEmitter {
|
|
|
456
465
|
type: "heartbeat",
|
|
457
466
|
uptime_sec: Math.floor((Date.now() - this.startedAt) / 1000),
|
|
458
467
|
bundle_version: this.bundleVersion,
|
|
468
|
+
...this.bundleStatus,
|
|
459
469
|
})
|
|
460
470
|
if (!ok) {
|
|
461
471
|
this.logger?.warn("heartbeat send failed, forcing reconnect")
|
|
@@ -495,6 +505,21 @@ export class WsClient extends EventEmitter {
|
|
|
495
505
|
}
|
|
496
506
|
}
|
|
497
507
|
|
|
508
|
+
/**
|
|
509
|
+
* CLI 別 bundle version + codex 承認状況を provider から読み直して `this.bundleStatus` を更新する。
|
|
510
|
+
* hello / heartbeat に spread して backend へ報告する(backend は per-CLI に stale 判定する)。
|
|
511
|
+
* 失敗しても空オブジェクトのまま(旧 backend / provider 未設定でも従来どおり動く)。
|
|
512
|
+
*/
|
|
513
|
+
_refreshBundleStatus() {
|
|
514
|
+
if (!this.bundleStatusProvider) return
|
|
515
|
+
try {
|
|
516
|
+
const s = this.bundleStatusProvider()
|
|
517
|
+
this.bundleStatus = s && typeof s === "object" ? s : {}
|
|
518
|
+
} catch (err) {
|
|
519
|
+
this.logger?.warn({ err: err.message }, "bundle status refresh failed")
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
498
523
|
/**
|
|
499
524
|
* `bundleManifestPath` を fs.watch で監視し、変更時に即時 heartbeat を送る。
|
|
500
525
|
* Cockpit UI への反映ラグを 30s heartbeat 周期から ~15s (UI poll) に短縮する。
|
|
@@ -553,10 +578,12 @@ export class WsClient extends EventEmitter {
|
|
|
553
578
|
this.bundleWatchDebounceTimer = null
|
|
554
579
|
const changed = this._refreshBundleVersion()
|
|
555
580
|
if (!changed) return
|
|
581
|
+
this._refreshBundleStatus()
|
|
556
582
|
const ok = this._sendJson({
|
|
557
583
|
type: "heartbeat",
|
|
558
584
|
uptime_sec: Math.floor((Date.now() - this.startedAt) / 1000),
|
|
559
585
|
bundle_version: this.bundleVersion,
|
|
586
|
+
...this.bundleStatus,
|
|
560
587
|
})
|
|
561
588
|
if (!ok) {
|
|
562
589
|
this.logger?.warn("bundle-change heartbeat send failed, forcing reconnect")
|