@cocorograph/hub-agent 0.5.27 → 0.5.29
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/main.mjs +48 -0
- package/src/pty-bridge.mjs +102 -1
- package/src/ws-client.mjs +39 -16
package/package.json
CHANGED
package/src/main.mjs
CHANGED
|
@@ -403,6 +403,45 @@ export async function maybeSyncClaudeSettings(msg, ctx) {
|
|
|
403
403
|
}
|
|
404
404
|
}
|
|
405
405
|
|
|
406
|
+
/**
|
|
407
|
+
* Plan ε: `agent.streams.sync.response` を受けて backend ↔ agent の現存
|
|
408
|
+
* stream_id を能動同期する。
|
|
409
|
+
*
|
|
410
|
+
* - local にあって backend にない → ptyBridge.detach で即時 kill (孤児)
|
|
411
|
+
* - backend にあって local にない → backend に pty.error を通知 (browser に
|
|
412
|
+
* "pty 死んでます" を表示するため)
|
|
413
|
+
*
|
|
414
|
+
* dispatch から呼ぶ用のヘルパーだが、テストから直接叩けるよう export している。
|
|
415
|
+
*/
|
|
416
|
+
export function handleStreamsSyncResponse(msg, ctx) {
|
|
417
|
+
const activeIds = new Set(
|
|
418
|
+
Array.isArray(msg?.active_stream_ids) ? msg.active_stream_ids : [],
|
|
419
|
+
)
|
|
420
|
+
const localIds = new Set(ctx.ptyBridge.list())
|
|
421
|
+
for (const sid of localIds) {
|
|
422
|
+
if (!activeIds.has(sid)) {
|
|
423
|
+
ctx.logger?.warn?.(
|
|
424
|
+
{ stream_id: sid, request_id: msg?.request_id },
|
|
425
|
+
"Plan ε: orphan stream detected, detaching",
|
|
426
|
+
)
|
|
427
|
+
ctx.ptyBridge.detach({ stream_id: sid })
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
for (const sid of activeIds) {
|
|
431
|
+
if (!localIds.has(sid)) {
|
|
432
|
+
ctx.logger?.warn?.(
|
|
433
|
+
{ stream_id: sid, request_id: msg?.request_id },
|
|
434
|
+
"Plan ε: stream missing on agent, notifying backend (pty.error)",
|
|
435
|
+
)
|
|
436
|
+
ctx.client.send({
|
|
437
|
+
type: "pty.error",
|
|
438
|
+
stream_id: sid,
|
|
439
|
+
error: "stream_not_found_on_agent",
|
|
440
|
+
})
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
406
445
|
async function dispatch(msg, ctx) {
|
|
407
446
|
const t = msg?.type || ""
|
|
408
447
|
try {
|
|
@@ -456,6 +495,15 @@ async function dispatch(msg, ctx) {
|
|
|
456
495
|
case "pty.detach":
|
|
457
496
|
ctx.ptyBridge.detach({ stream_id: msg.stream_id })
|
|
458
497
|
return
|
|
498
|
+
case "agent.streams.sync.response":
|
|
499
|
+
// Plan ε: backend ↔ agent の現存 stream_id 能動同期 (WS reconnect 直後に
|
|
500
|
+
// ws-client が agent.streams.sync.request を送って取得した結果)。
|
|
501
|
+
// ロジックは handleStreamsSyncResponse へ抽出 (テストから直接呼べる)。
|
|
502
|
+
// backend が旧 (= Plan ε 未対応) で response が一切来ないケースは何もしない。
|
|
503
|
+
// 5s タイムアウトのような明示的な timer は持たず、再接続時に再度 request
|
|
504
|
+
// が飛ぶので無害。
|
|
505
|
+
handleStreamsSyncResponse(msg, ctx)
|
|
506
|
+
return
|
|
459
507
|
case "tmux.exec": {
|
|
460
508
|
const args = Array.isArray(msg.args) ? msg.args : []
|
|
461
509
|
const hookResult = await runHookChain(ctx.plugins, "interceptTmuxExec", {
|
package/src/pty-bridge.mjs
CHANGED
|
@@ -28,6 +28,18 @@ const DEFAULT_ROWS = 32
|
|
|
28
28
|
// に見える (1 フレーム 16.7ms より短いので体感レイテンシは増えない)。
|
|
29
29
|
const DEFAULT_COALESCE_MS = 12
|
|
30
30
|
|
|
31
|
+
// orphan stream GC 設定。
|
|
32
|
+
// - DEFAULT_GC_INTERVAL_MS: GC スキャン周期 (60s)。短すぎても CPU 負担、長すぎても
|
|
33
|
+
// pty 枯渇に到達してから掃除されるまでに時間がかかる。1 分は妥当な balance。
|
|
34
|
+
// - DEFAULT_GC_STALE_MS: stream idle 判定の閾値 (10 min)。Cockpit window が
|
|
35
|
+
// 通常使用中は 5s 周期で session.state 等で touch されるので絶対に達しない。
|
|
36
|
+
// リロード時の切断 (秒〜十数秒) も無傷。何時間も放置された孤児だけ掃除される。
|
|
37
|
+
// ※ アクティブ stream が「10 分間まったく操作されない」= ユーザー席離れ等。
|
|
38
|
+
// その場合のみ kill されるが、tmux session 自体は別 process なので作業内容
|
|
39
|
+
// は無傷、リロードで再 attach できる。
|
|
40
|
+
const DEFAULT_GC_INTERVAL_MS = 60_000
|
|
41
|
+
const DEFAULT_GC_STALE_MS = 10 * 60_000
|
|
42
|
+
|
|
31
43
|
function resolveBin(name) {
|
|
32
44
|
try {
|
|
33
45
|
return execFileSync("/usr/bin/which", [name], { encoding: "utf-8" }).trim()
|
|
@@ -53,7 +65,32 @@ export class PtyBridge extends EventEmitter {
|
|
|
53
65
|
* `/bin/sh -c "exec tmux attach -t <sessionName>"`
|
|
54
66
|
* @param {number} [opts.coalesceMs] - pty 出力を coalesce する間隔 (ms)。0 で無効。
|
|
55
67
|
*/
|
|
56
|
-
constructor({
|
|
68
|
+
constructor({
|
|
69
|
+
ptyModule,
|
|
70
|
+
logger,
|
|
71
|
+
plugins = [],
|
|
72
|
+
defaultSpawnCommand,
|
|
73
|
+
coalesceMs,
|
|
74
|
+
/**
|
|
75
|
+
* 孤児 stream の GC 設定。
|
|
76
|
+
*
|
|
77
|
+
* 背景: 旧設計では WS reconnect / Cockpit window リロード / タブ閉じ等で
|
|
78
|
+
* `pty.detach` が agent に届かないケースがあり、pty fd が leak していた。
|
|
79
|
+
* macOS の `kern.tty.ptmx_max` は 511 個でその上限に到達すると新規
|
|
80
|
+
* posix_spawnp が `EAGAIN` で失敗する (= "posix_spawnp failed" の真因)。
|
|
81
|
+
*
|
|
82
|
+
* 対処: 各 stream に `lastSeenAt` を持たせ、`attach` / `write` / `resize`
|
|
83
|
+
* のたびに touch する。`gcIntervalMs` 周期で `gcStaleMs` を超えた stream を
|
|
84
|
+
* 自動 detach する (= orphan GC)。**アクティブな stream は browser からの
|
|
85
|
+
* pty.data / pty.resize で常に touch されるため絶対に kill されない**。
|
|
86
|
+
* リロード時の短時間切断 (秒〜十数秒) も `gcStaleMs` (デフォルト 10 分)
|
|
87
|
+
* 未満なので無傷。何時間も放置された孤児だけ掃除される。
|
|
88
|
+
*
|
|
89
|
+
* gcStaleMs を 0 以下にすると GC を無効化する (旧挙動)。テスト・特殊用途向け。
|
|
90
|
+
*/
|
|
91
|
+
gcIntervalMs,
|
|
92
|
+
gcStaleMs,
|
|
93
|
+
} = {}) {
|
|
57
94
|
super()
|
|
58
95
|
if (!ptyModule || typeof ptyModule.spawn !== "function") {
|
|
59
96
|
throw new TypeError("PtyBridge requires { ptyModule: { spawn } }")
|
|
@@ -73,6 +110,59 @@ export class PtyBridge extends EventEmitter {
|
|
|
73
110
|
this.streams = new Map()
|
|
74
111
|
/** @type {Map<string, {buf: string, timer: ReturnType<typeof setTimeout>|null}>} */
|
|
75
112
|
this.coalesceState = new Map()
|
|
113
|
+
|
|
114
|
+
// orphan GC 設定。デフォルトは 1 分周期で 10 分間 idle の stream を kill。
|
|
115
|
+
this.gcIntervalMs = gcIntervalMs ?? DEFAULT_GC_INTERVAL_MS
|
|
116
|
+
this.gcStaleMs = gcStaleMs ?? DEFAULT_GC_STALE_MS
|
|
117
|
+
/** @type {Map<string, number>} stream_id → lastSeenAt (ms epoch) */
|
|
118
|
+
this.lastSeenAt = new Map()
|
|
119
|
+
/** @type {ReturnType<typeof setInterval>|null} */
|
|
120
|
+
this._gcTimer = null
|
|
121
|
+
if (this.gcStaleMs > 0 && this.gcIntervalMs > 0) {
|
|
122
|
+
this._gcTimer = setInterval(() => this._gcStaleStreams(), this.gcIntervalMs)
|
|
123
|
+
// unref で Node の event loop を止めない (test 環境で hang しないため)。
|
|
124
|
+
if (typeof this._gcTimer.unref === "function") this._gcTimer.unref()
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* 現在時刻を ms epoch で返す。テストで now を差し替えるため override 可能に。
|
|
130
|
+
* @returns {number}
|
|
131
|
+
*/
|
|
132
|
+
_now() {
|
|
133
|
+
return Date.now()
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* stream_id を「最近 browser からアクセスがあった」と record する。
|
|
138
|
+
* attach / write / resize の各 entry point から呼ぶ。
|
|
139
|
+
* @param {string} stream_id
|
|
140
|
+
*/
|
|
141
|
+
_touch(stream_id) {
|
|
142
|
+
this.lastSeenAt.set(stream_id, this._now())
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* `gcStaleMs` を超えて idle な stream を一括 detach する。
|
|
147
|
+
*
|
|
148
|
+
* `_gcTimer` から `gcIntervalMs` 周期で呼ばれる。手動でも呼べる (テスト用)。
|
|
149
|
+
* @returns {string[]} kill した stream_id 一覧 (debug / test 用)
|
|
150
|
+
*/
|
|
151
|
+
_gcStaleStreams() {
|
|
152
|
+
if (!(this.gcStaleMs > 0)) return []
|
|
153
|
+
const cutoff = this._now() - this.gcStaleMs
|
|
154
|
+
const killed = []
|
|
155
|
+
for (const [stream_id, ts] of this.lastSeenAt) {
|
|
156
|
+
if (ts < cutoff && this.streams.has(stream_id)) {
|
|
157
|
+
this.logger?.warn(
|
|
158
|
+
{ stream_id, stale_ms: this._now() - ts, gc_stale_ms: this.gcStaleMs },
|
|
159
|
+
"pty GC: orphaned stream killed",
|
|
160
|
+
)
|
|
161
|
+
this.detach({ stream_id })
|
|
162
|
+
killed.push(stream_id)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return killed
|
|
76
166
|
}
|
|
77
167
|
|
|
78
168
|
/**
|
|
@@ -134,6 +224,7 @@ export class PtyBridge extends EventEmitter {
|
|
|
134
224
|
}
|
|
135
225
|
|
|
136
226
|
this.streams.set(stream_id, pty)
|
|
227
|
+
this._touch(stream_id)
|
|
137
228
|
this.logger?.info(
|
|
138
229
|
{ stream_id, sessionName, command: spec.command, plugin: hookResult?.plugin || null },
|
|
139
230
|
"pty attached",
|
|
@@ -164,6 +255,7 @@ export class PtyBridge extends EventEmitter {
|
|
|
164
255
|
this._flushCoalesce(stream_id)
|
|
165
256
|
this.coalesceState.delete(stream_id)
|
|
166
257
|
this.streams.delete(stream_id)
|
|
258
|
+
this.lastSeenAt.delete(stream_id)
|
|
167
259
|
this.emit("exit", { stream_id, code: exitCode })
|
|
168
260
|
})
|
|
169
261
|
|
|
@@ -181,6 +273,7 @@ export class PtyBridge extends EventEmitter {
|
|
|
181
273
|
this.logger?.warn({ stream_id }, "pty.write but stream missing")
|
|
182
274
|
return false
|
|
183
275
|
}
|
|
276
|
+
this._touch(stream_id)
|
|
184
277
|
pty.write(data)
|
|
185
278
|
return true
|
|
186
279
|
}
|
|
@@ -192,6 +285,7 @@ export class PtyBridge extends EventEmitter {
|
|
|
192
285
|
this.logger?.warn({ stream_id }, "pty.resize but stream missing")
|
|
193
286
|
return false
|
|
194
287
|
}
|
|
288
|
+
this._touch(stream_id)
|
|
195
289
|
try {
|
|
196
290
|
pty.resize(cols, rows)
|
|
197
291
|
return true
|
|
@@ -208,6 +302,9 @@ export class PtyBridge extends EventEmitter {
|
|
|
208
302
|
// kill 前に残バッファを emit しておく (onExit にも flush はあるが、
|
|
209
303
|
// detach は browser 側都合なので最新を確実に届けたい)。
|
|
210
304
|
this._flushCoalesce(stream_id)
|
|
305
|
+
// GC tracking もここでクリア。pty.onExit でも消すが、onExit が来る前に
|
|
306
|
+
// 同じ stream_id が再 attach されると古い lastSeenAt が残るリスクを避ける。
|
|
307
|
+
this.lastSeenAt.delete(stream_id)
|
|
211
308
|
try {
|
|
212
309
|
pty.kill()
|
|
213
310
|
} catch {
|
|
@@ -218,6 +315,10 @@ export class PtyBridge extends EventEmitter {
|
|
|
218
315
|
|
|
219
316
|
/** 全 pty を即時 kill (agent shutdown 用)。 */
|
|
220
317
|
shutdown() {
|
|
318
|
+
if (this._gcTimer) {
|
|
319
|
+
clearInterval(this._gcTimer)
|
|
320
|
+
this._gcTimer = null
|
|
321
|
+
}
|
|
221
322
|
for (const stream_id of Array.from(this.streams.keys())) {
|
|
222
323
|
this.detach({ stream_id })
|
|
223
324
|
}
|
package/src/ws-client.mjs
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*
|
|
12
12
|
* 仕様書: ナレッジ/インフラ/cockpit-hub-hosted-integration-spec (id=6080)
|
|
13
13
|
*/
|
|
14
|
+
import { randomUUID } from "node:crypto"
|
|
14
15
|
import { EventEmitter } from "node:events"
|
|
15
16
|
import fs from "node:fs"
|
|
16
17
|
import os from "node:os"
|
|
@@ -68,22 +69,7 @@ export class WsClient extends EventEmitter {
|
|
|
68
69
|
const ws = new WebSocket(wsUrl, { headers })
|
|
69
70
|
this.ws = ws
|
|
70
71
|
|
|
71
|
-
ws.on("open", () =>
|
|
72
|
-
this.backoff = MIN_BACKOFF_MS
|
|
73
|
-
this.logger?.info("ws open")
|
|
74
|
-
// hello 前に最新の bundle version を読み直す (再接続時の鮮度確保)
|
|
75
|
-
this._refreshBundleVersion()
|
|
76
|
-
this._sendJson({
|
|
77
|
-
type: "hello",
|
|
78
|
-
agent_id: this.config.agent_id,
|
|
79
|
-
hostname: this.hostname,
|
|
80
|
-
version: this.version,
|
|
81
|
-
bundle_version: this.bundleVersion,
|
|
82
|
-
})
|
|
83
|
-
this._startHeartbeat()
|
|
84
|
-
this._startBundleWatcher()
|
|
85
|
-
this.emit("open")
|
|
86
|
-
})
|
|
72
|
+
ws.on("open", () => this._onOpen())
|
|
87
73
|
|
|
88
74
|
ws.on("message", (data) => {
|
|
89
75
|
let msg
|
|
@@ -111,6 +97,43 @@ export class WsClient extends EventEmitter {
|
|
|
111
97
|
})
|
|
112
98
|
}
|
|
113
99
|
|
|
100
|
+
/**
|
|
101
|
+
* WS open 時の初期送信 + 後続タスク起動。
|
|
102
|
+
*
|
|
103
|
+
* - backoff をリセット
|
|
104
|
+
* - bundle version を最新化 → hello 送信
|
|
105
|
+
* - Plan ε: agent.streams.sync.request を送信 (毎 reconnect で能動同期)
|
|
106
|
+
* - heartbeat + bundle watcher を起動
|
|
107
|
+
*
|
|
108
|
+
* test からも直接呼べるように method として切り出している (`ws.on("open")`
|
|
109
|
+
* のクロージャだと spy しづらい)。
|
|
110
|
+
*/
|
|
111
|
+
_onOpen() {
|
|
112
|
+
this.backoff = MIN_BACKOFF_MS
|
|
113
|
+
this.logger?.info("ws open")
|
|
114
|
+
// hello 前に最新の bundle version を読み直す (再接続時の鮮度確保)
|
|
115
|
+
this._refreshBundleVersion()
|
|
116
|
+
this._sendJson({
|
|
117
|
+
type: "hello",
|
|
118
|
+
agent_id: this.config.agent_id,
|
|
119
|
+
hostname: this.hostname,
|
|
120
|
+
version: this.version,
|
|
121
|
+
bundle_version: this.bundleVersion,
|
|
122
|
+
})
|
|
123
|
+
// Plan ε: 毎回 reconnect 直後に backend ↔ agent の stream_id を能動同期する
|
|
124
|
+
// (orphan stream の即時 kill 用)。response は main.mjs の dispatch が拾い、
|
|
125
|
+
// ptyBridge.list() と差分を取って kill する。request_id は uuid で
|
|
126
|
+
// 重複排除。backend が古い (= Plan ε 未対応) なら "unknown_type" error が
|
|
127
|
+
// 返るだけで動作には影響しない。
|
|
128
|
+
this._sendJson({
|
|
129
|
+
type: "agent.streams.sync.request",
|
|
130
|
+
request_id: randomUUID(),
|
|
131
|
+
})
|
|
132
|
+
this._startHeartbeat()
|
|
133
|
+
this._startBundleWatcher()
|
|
134
|
+
this.emit("open")
|
|
135
|
+
}
|
|
136
|
+
|
|
114
137
|
/** メッセージを送る。未接続なら no-op (logger.warn)。 */
|
|
115
138
|
send(obj) {
|
|
116
139
|
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|