@cocorograph/hub-agent 0.6.86 → 0.6.87

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.6.86",
3
+ "version": "0.6.87",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
package/src/main.mjs CHANGED
@@ -1137,6 +1137,20 @@ export function handleTrackedPtyData(msg, ctx) {
1137
1137
  }
1138
1138
  }
1139
1139
 
1140
+ /**
1141
+ * 無印 (input_id 無し / fire-and-forget) pty.data の処理。raw 打鍵 (term.onData) が
1142
+ * これを通る。stream 不在 (reap 済み等) なら pty.error を返して browser を再 attach →
1143
+ * 自己修復させる (T-6)。従来は write の戻り値を見ずに捨てていたため、reap 済み stream
1144
+ * への打鍵が無言ドロップされていた。tracked 経路 (handleTrackedPtyData) と対称。
1145
+ * テストから直接呼べるよう dispatch から切り出して export する。
1146
+ */
1147
+ export function handleUntrackedPtyData(msg, ctx) {
1148
+ const { stream_id } = msg
1149
+ if (!ctx.ptyBridge.write({ stream_id, data: msg.data })) {
1150
+ ctx.client.send({ type: "pty.error", stream_id, error: "stream_missing" })
1151
+ }
1152
+ }
1153
+
1140
1154
  async function dispatch(msg, ctx) {
1141
1155
  const t = msg?.type || ""
1142
1156
  try {
@@ -1203,7 +1217,7 @@ async function dispatch(msg, ctx) {
1203
1217
  handleTrackedPtyData(msg, ctx)
1204
1218
  return
1205
1219
  }
1206
- ctx.ptyBridge.write({ stream_id: msg.stream_id, data: msg.data })
1220
+ handleUntrackedPtyData(msg, ctx)
1207
1221
  return
1208
1222
  case "pty.resize":
1209
1223
  ctx.ptyBridge.resize({
@@ -86,9 +86,11 @@ export class PtyBridge extends EventEmitter {
86
86
  * posix_spawnp が `EAGAIN` で失敗する (= "posix_spawnp failed" の真因)。
87
87
  *
88
88
  * 対処: 各 stream に `lastSeenAt` を持たせ、`attach` / `write` / `resize`
89
- * のたびに touch する。`gcIntervalMs` 周期で `gcStaleMs` を超えた stream を
90
- * 自動 detach する (= orphan GC)。**アクティブな stream は browser からの
91
- * pty.data / pty.resize で常に touch されるため絶対に kill されない**。
89
+ * および **pty 出力 (onData)** のたびに touch する。`gcIntervalMs` 周期で
90
+ * `gcStaleMs` を超えた stream を自動 detach する (= orphan GC)。**アクティブな
91
+ * stream は browser からの pty.data / pty.resize、または claude の出力で常に
92
+ * touch されるため絶対に kill されない** (生成中で出力は流れるが入力が間遠い
93
+ * 閲覧中の TUI チャットが reap される A-4 を出力 touch で塞ぐ)。
92
94
  * リロード時の短時間切断 (秒〜十数秒) も `gcStaleMs` (デフォルト 10 分)
93
95
  * 未満なので無傷。何時間も放置された孤児だけ掃除される。
94
96
  *
@@ -382,6 +384,16 @@ export class PtyBridge extends EventEmitter {
382
384
  this.coalesceState.set(stream_id, state)
383
385
  disposables.push(
384
386
  pty.onData((data) => {
387
+ // 出力でも lastSeenAt を touch する (A-4 修正)。orphan GC は lastSeenAt が
388
+ // gcStaleMs 古い stream を reap するが、従来は write/resize/attach でしか
389
+ // touch していなかったため「生成中で出力は流れているが入力が間遠い閲覧中の
390
+ // TUI チャット」が 10 分で reap され、以後の送信が stream_missing で落ちていた
391
+ // (本番 agent.log で reaped_total 多数 + 同 input_id の再送 dedup を観測)。
392
+ // 出力が流れている = まだ生きて使われている stream なので touch して延命する。
393
+ // 真にブラウザが去った stream は claude がターン完了後に redraw を止めて出力が
394
+ // 止まり、gcStaleMs 経過で reap される。万一の runaway 出力 orphan は maxStreams
395
+ // 上限の最古 reap が backstop になる。
396
+ this._touch(stream_id)
385
397
  state.buf += data
386
398
  if (!state.timer) {
387
399
  state.timer = setTimeout(() => {
@@ -397,6 +409,8 @@ export class PtyBridge extends EventEmitter {
397
409
  } else {
398
410
  disposables.push(
399
411
  pty.onData((data) => {
412
+ // 出力でも touch (A-4 修正)。理由は上の coalesce 経路のコメント参照。
413
+ this._touch(stream_id)
400
414
  this.emit("output", { stream_id, data })
401
415
  }),
402
416
  )
package/src/ws-client.mjs CHANGED
@@ -58,6 +58,31 @@ const PTY_BUFFER_MAX_FRAMES = 500
58
58
  // 30 秒以上経過した pty.data は flush 時に破棄する。
59
59
  const PTY_BUFFER_MAX_AGE_MS = 30_000
60
60
 
61
+ // agent→browser の制御/イベント系メッセージのうち、WS not OPEN 中に捨てると
62
+ // Cockpit の挙動が壊れるものを reconnect 越しに保持する (機構③: 本番 agent.log で
63
+ // `claude.jsonl.event ... "ws send skipped (not open)"` のドロップを観測)。
64
+ // - pty.input.ack: 喪失すると browser の pendingAcks が永久滞留し「送信待ち(未配信)」が
65
+ // 消えない (S1/S3)。再接続 flush で遅れて届けば即解消する。
66
+ // - claude.jsonl.event / claude.event / session.event: 喪失すると新規セッションの
67
+ // 楽観バブルが本物へ昇格できず「生成は始まったがバブルが出ない」(S5)。
68
+ // - pty.exit / pty.ready / pty.error: stream ライフサイクルの節目。落とすと browser の
69
+ // 再 attach/復帰判断が狂う。
70
+ // pty.data とは別バッファにするのは、高頻度な pty.data の洪水で低頻度な ack/event が
71
+ // リング evict されるのを防ぐため (pty.data は冪等で最新フレームだけ届けば良いが、
72
+ // ack/event は 1 件ずつ意味を持つ)。
73
+ const BUFFERED_CTRL_TYPES = new Set([
74
+ "pty.input.ack",
75
+ "pty.exit",
76
+ "pty.ready",
77
+ "pty.error",
78
+ "claude.jsonl.event",
79
+ "claude.event",
80
+ "session.event",
81
+ ])
82
+ // 制御/イベントバッファの上限。低頻度なので frames は小さめ、age は pty と揃える。
83
+ const CTRL_BUFFER_MAX_FRAMES = 200
84
+ const CTRL_BUFFER_MAX_AGE_MS = 30_000
85
+
61
86
  export class WsClient extends EventEmitter {
62
87
  /**
63
88
  * @param {{ hub_url: string, agent_id: string, agent_token: string }} config
@@ -94,6 +119,9 @@ export class WsClient extends EventEmitter {
94
119
  // pty.data の outbound buffer (WS not OPEN 中に積み、open 後 flush)。
95
120
  // entry: { obj, ts }
96
121
  this.ptyOutboundBuffer = []
122
+ // 制御/イベント系 (ack / jsonl.event 等) の outbound buffer。pty.data とは別管理で、
123
+ // 高頻度 pty.data の洪水に evict されないようにする (機構③)。entry: { obj, ts }
124
+ this.ctrlOutboundBuffer = []
97
125
  // 直前の close 原因が CF/origin の 5xx か。次回 backoff の下限を引き上げる。
98
126
  this.lastCloseWas5xx = false
99
127
  // 接続が STABLE_CONNECTION_MS 維持できたら backoff/5xx フラグをリセットする
@@ -203,23 +231,30 @@ export class WsClient extends EventEmitter {
203
231
  request_id: randomUUID(),
204
232
  })
205
233
  this._flushPtyBuffer()
234
+ this._flushCtrlBuffer()
206
235
  this._startHeartbeat()
207
236
  this._startBundleWatcher()
208
237
  this.emit("open")
209
238
  }
210
239
 
211
- /** メッセージを送る。未接続時は pty.data だけ buffer に積み、reconnect 後に flush。
240
+ /** メッセージを送る。未接続時は pty.data と制御/イベント系 (ack / jsonl.event 等) を
241
+ * buffer に積み、reconnect 後に flush する。
212
242
  *
213
- * heartbeat / hello / agent.streams.sync.* など制御系は古くなると意味が無いので
214
- * buffer しない (warn のみ)。
243
+ * heartbeat / hello / agent.streams.sync.* など「古くなると意味が無い」制御系は
244
+ * buffer しない (warn のみ)。ack / event 系は 1 件ずつ意味を持つので別バッファに保持する。
215
245
  */
216
246
  send(obj) {
217
247
  if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
218
- if (obj?.type === "pty.data") {
248
+ const type = obj?.type
249
+ if (type === "pty.data") {
219
250
  this._bufferPtyData(obj)
220
251
  return false
221
252
  }
222
- this.logger?.warn({ type: obj?.type }, "ws send skipped (not open)")
253
+ if (BUFFERED_CTRL_TYPES.has(type)) {
254
+ this._bufferCtrl(obj)
255
+ return false
256
+ }
257
+ this.logger?.warn({ type }, "ws send skipped (not open)")
223
258
  return false
224
259
  }
225
260
  return this._sendJson(obj)
@@ -269,6 +304,48 @@ export class WsClient extends EventEmitter {
269
304
  )
270
305
  }
271
306
 
307
+ /** 制御/イベント系 (ack / jsonl.event 等) を outbound buffer に積む。リング (drop oldest)。 */
308
+ _bufferCtrl(obj) {
309
+ this.ctrlOutboundBuffer.push({ obj, ts: Date.now() })
310
+ if (this.ctrlOutboundBuffer.length > CTRL_BUFFER_MAX_FRAMES) {
311
+ const dropped = this.ctrlOutboundBuffer.length - CTRL_BUFFER_MAX_FRAMES
312
+ this.ctrlOutboundBuffer.splice(0, dropped)
313
+ this.logger?.warn(
314
+ { dropped, kept: this.ctrlOutboundBuffer.length },
315
+ "ctrl outbound buffer overflow (oldest dropped)"
316
+ )
317
+ }
318
+ }
319
+
320
+ /** open 直後に制御/イベントバッファを flush。古すぎる entry は破棄する。
321
+ * 構造は _flushPtyBuffer と同じ (退避 → 期限切れ skip → 送信失敗で残りを順序保持で戻す)。
322
+ */
323
+ _flushCtrlBuffer() {
324
+ if (this.ctrlOutboundBuffer.length === 0) return
325
+ const now = Date.now()
326
+ const buf = this.ctrlOutboundBuffer
327
+ this.ctrlOutboundBuffer = []
328
+ let sent = 0
329
+ let expired = 0
330
+ for (let i = 0; i < buf.length; i++) {
331
+ const entry = buf[i]
332
+ if (now - entry.ts > CTRL_BUFFER_MAX_AGE_MS) {
333
+ expired += 1
334
+ continue
335
+ }
336
+ const ok = this._sendJson(entry.obj)
337
+ if (!ok) {
338
+ this.ctrlOutboundBuffer = buf.slice(i).concat(this.ctrlOutboundBuffer)
339
+ break
340
+ }
341
+ sent += 1
342
+ }
343
+ this.logger?.info(
344
+ { sent, expired, total: buf.length, requeued: this.ctrlOutboundBuffer.length },
345
+ "ctrl outbound buffer flushed"
346
+ )
347
+ }
348
+
272
349
  /** Reconnect を止めて切断する。 */
273
350
  stop() {
274
351
  this.stopped = true