@cocorograph/hub-agent 0.7.13 → 0.7.15
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/chat-signals.mjs +41 -1
- package/src/main.mjs +24 -4
- package/src/ws-client.mjs +33 -11
package/package.json
CHANGED
package/src/chat-signals.mjs
CHANGED
|
@@ -35,6 +35,17 @@ const _byCwd = new Map()
|
|
|
35
35
|
// 一次対策で、本 TTL はその取りこぼし時のバックストップ。
|
|
36
36
|
const CHAT_SIGNAL_STALE_MS = 90 * 1000
|
|
37
37
|
|
|
38
|
+
// RC4 (症状④): stream_event (拡張思考の thinking_delta / 長時間ツール中の partial delta)
|
|
39
|
+
// 受信を status=processing の tick として記録する際の cwd 単位スロットル間隔。stream_event は
|
|
40
|
+
// トークン delta 毎に高頻度で流れるため、無制限に recordChatActivity を呼ぶと _byCwd への書き込み
|
|
41
|
+
// 洪水になる。この間隔で間引いても、>90s の拡張思考バースト / 単発ツールの間 statusAt・updatedAtMs
|
|
42
|
+
// を確実に前進させ、CHAT_SIGNAL_STALE_MS による idle 誤降格 (純 SDK チャットで三点リーダーが消える
|
|
43
|
+
// 事故) を防げる。
|
|
44
|
+
const STREAM_TICK_THROTTLE_MS = 3 * 1000
|
|
45
|
+
|
|
46
|
+
// cwd → 最後に stream tick を記録した時刻 (スロットル判定用)。
|
|
47
|
+
const _lastStreamTickAt = new Map()
|
|
48
|
+
|
|
38
49
|
const VALID_STATUS = new Set(["processing", "waiting", "idle"])
|
|
39
50
|
|
|
40
51
|
function _now() {
|
|
@@ -86,6 +97,33 @@ export function recordChatActivity(cwd, patch = {}, now = _now()) {
|
|
|
86
97
|
_byCwd.set(cwd, next)
|
|
87
98
|
}
|
|
88
99
|
|
|
100
|
+
/**
|
|
101
|
+
* RC4 (症状④): stream_event (拡張思考 thinking_delta / 長時間ツール中の partial delta) 受信を
|
|
102
|
+
* status=processing の「生きている」tick として記録する。assistant / result が流れない長い無音
|
|
103
|
+
* 区間 (>90s) でも statusAt・updatedAtMs を前進させ、getChatSignal が CHAT_SIGNAL_STALE_MS で
|
|
104
|
+
* null を返して三点リーダーが消える事故を防ぐ。
|
|
105
|
+
*
|
|
106
|
+
* 設計上の注意:
|
|
107
|
+
* - cwd 単位で STREAM_TICK_THROTTLE_MS に間引く (delta 毎の Map 書き込み洪水を回避)。
|
|
108
|
+
* - delta 本文は一切読まない (タイムスタンプ前進のみ)。拡張思考の本文は ephemeral で、ここで
|
|
109
|
+
* 保持・転送する必要は無く、プライバシー上も読むべきでない。
|
|
110
|
+
* - SDK のイベント順序上 stream_event はターン中の assistant ブロックに属し result より前に
|
|
111
|
+
* 流れる。abort 時は SDK query が即停止して onEvent が止まるため、result/turnsettled で
|
|
112
|
+
* waiting に落ちた後に trailing stream_event が processing を蘇生させる経路は無い。
|
|
113
|
+
*
|
|
114
|
+
* @param {string} cwd
|
|
115
|
+
* @param {number} [now]
|
|
116
|
+
*/
|
|
117
|
+
export function recordChatStreamTick(cwd, now = _now()) {
|
|
118
|
+
if (!cwd || typeof cwd !== "string") return
|
|
119
|
+
// 初回 (未 tick) は必ず記録する。`|| 0` で last を 0 既定にすると「時刻 0 で tick 済み」と
|
|
120
|
+
// 区別できず、初回 tick が now < throttle のとき誤って間引かれる。has で未 tick を判定する。
|
|
121
|
+
const last = _lastStreamTickAt.get(cwd)
|
|
122
|
+
if (last !== undefined && now - last < STREAM_TICK_THROTTLE_MS) return
|
|
123
|
+
_lastStreamTickAt.set(cwd, now)
|
|
124
|
+
recordChatActivity(cwd, { status: "processing" }, now)
|
|
125
|
+
}
|
|
126
|
+
|
|
89
127
|
/**
|
|
90
128
|
* cwd に対応する生きているチャット信号を返す。stale / 不在なら null。
|
|
91
129
|
*
|
|
@@ -116,11 +154,13 @@ export function getChatSignal(cwd, now = _now()) {
|
|
|
116
154
|
export function clearChatSignal(cwd) {
|
|
117
155
|
if (!cwd || typeof cwd !== "string") return
|
|
118
156
|
_byCwd.delete(cwd)
|
|
157
|
+
_lastStreamTickAt.delete(cwd)
|
|
119
158
|
}
|
|
120
159
|
|
|
121
160
|
/** テスト用: ストアを空にする。 */
|
|
122
161
|
export function _resetChatSignals() {
|
|
123
162
|
_byCwd.clear()
|
|
163
|
+
_lastStreamTickAt.clear()
|
|
124
164
|
}
|
|
125
165
|
|
|
126
|
-
export { CHAT_SIGNAL_STALE_MS }
|
|
166
|
+
export { CHAT_SIGNAL_STALE_MS, STREAM_TICK_THROTTLE_MS }
|
package/src/main.mjs
CHANGED
|
@@ -92,7 +92,12 @@ import {
|
|
|
92
92
|
getUsage,
|
|
93
93
|
recordChatRateLimit,
|
|
94
94
|
} from "./usage.mjs"
|
|
95
|
-
import {
|
|
95
|
+
import {
|
|
96
|
+
clearChatSignal,
|
|
97
|
+
getChatSignal,
|
|
98
|
+
recordChatActivity,
|
|
99
|
+
recordChatStreamTick,
|
|
100
|
+
} from "./chat-signals.mjs"
|
|
96
101
|
import {
|
|
97
102
|
ReadinessTracker,
|
|
98
103
|
busyChildCount,
|
|
@@ -491,6 +496,17 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
491
496
|
} catch {
|
|
492
497
|
/* ignore */
|
|
493
498
|
}
|
|
499
|
+
} else if (event?.type === "stream_event") {
|
|
500
|
+
// RC4 (症状④): 拡張思考 (thinking_delta) や長時間ツール中の partial delta では
|
|
501
|
+
// assistant / result が流れず statusAt / updatedAtMs が前進しないため、>90s の無音で
|
|
502
|
+
// getChatSignal が CHAT_SIGNAL_STALE_MS により null を返し、capture-pane が idle な
|
|
503
|
+
// 純 SDK チャットで三点リーダーが消える。stream_event 受信を throttle 付き processing
|
|
504
|
+
// tick として記録し statusAt を前進させる (本文は読まずタイムスタンプ前進のみ)。
|
|
505
|
+
try {
|
|
506
|
+
recordChatStreamTick(cwd)
|
|
507
|
+
} catch {
|
|
508
|
+
/* ignore */
|
|
509
|
+
}
|
|
494
510
|
}
|
|
495
511
|
client.send({ type: "claude.event", stream_id, session_id, event })
|
|
496
512
|
})
|
|
@@ -1656,9 +1672,13 @@ export async function handleTuiInterrupt(msg, ctx) {
|
|
|
1656
1672
|
const sendEsc = ctx.sendInterruptKey || sendInterruptKey
|
|
1657
1673
|
const detect = ctx.detectSessionState || detectSessionState
|
|
1658
1674
|
const delay = ctx.delay || ((ms) => new Promise((r) => setTimeout(r, ms)))
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1675
|
+
// 症状⑤: 初回チェックまでの settle と poll 間隔を 350→150ms に詰め、maxPolls を 7→16 に
|
|
1676
|
+
// 増やす。総 deadline は ~2.4s で従来(~2.5s)とほぼ同じだが、実際に停止した瞬間の観測が
|
|
1677
|
+
// 約2倍速くなり「停止押下→画面が止まる」体感ラグを縮める。capture-pane スクレイプ回数は
|
|
1678
|
+
// 増えるが 150ms 間隔なら許容範囲。Ctrl+C 非エスカレーション方針は不変。
|
|
1679
|
+
const settleMs = ctx.interruptSettleMs ?? 150
|
|
1680
|
+
const pollMs = ctx.interruptPollMs ?? 150
|
|
1681
|
+
const maxPolls = ctx.interruptMaxPolls ?? 16 // ~2.4s deadline (150 + 150*15)
|
|
1662
1682
|
const reply = (stopped, attempts) =>
|
|
1663
1683
|
ctx.client.send({
|
|
1664
1684
|
type: "claude.tui.interrupt.result",
|
package/src/ws-client.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* - 接続: `Authorization: Bearer <agent_id>:<agent_token>`
|
|
5
5
|
* - 起動時に `hello`、30s おきに `heartbeat` を送信
|
|
6
|
-
* - 切断時は exponential backoff (1s, 2s, 4s,
|
|
6
|
+
* - 切断時は exponential backoff (1s, 2s, 4s, 8s max) で再接続
|
|
7
7
|
* - サーバから受け取った JSON は `onMessage` callback に渡す
|
|
8
8
|
* - `bundleVersionProvider` + `bundleManifestPath` を渡すと、manifest.json の
|
|
9
9
|
* 変更を fs.watch で検知して即時 heartbeat を送信し Cockpit UI に最新版を
|
|
@@ -20,7 +20,10 @@ import WebSocket from "ws"
|
|
|
20
20
|
|
|
21
21
|
const HEARTBEAT_INTERVAL_MS = 30_000
|
|
22
22
|
const MIN_BACKOFF_MS = 1_000
|
|
23
|
-
|
|
23
|
+
// 症状①(接続復帰の遅さ)対策: 旧 30s。本番ログで再接続 p50=1.9s に対し、フラッピング時に
|
|
24
|
+
// backoff が指数増加して復帰レイテンシのテール(p90=9s, 最大35s)を作っていた。実測の切断は
|
|
25
|
+
// ほぼ瞬断(CF/NW)で 8s も待てば回復に十分。jitter(±20%)で thundering herd は引き続き回避。
|
|
26
|
+
const MAX_BACKOFF_MS = 8_000
|
|
24
27
|
const BUNDLE_WATCH_DEBOUNCE_MS = 500
|
|
25
28
|
|
|
26
29
|
// zombie WS (half-open TCP) 検知のしきい値。backend は heartbeat (30s) のたびに
|
|
@@ -196,15 +199,18 @@ export class WsClient extends EventEmitter {
|
|
|
196
199
|
* のクロージャだと spy しづらい)。
|
|
197
200
|
*/
|
|
198
201
|
_onOpen() {
|
|
199
|
-
// backoff の即時リセットはしない。接続が STABLE_CONNECTION_MS 維持できてから
|
|
200
|
-
// _armStableReset() でリセットする (open→即 close フラッピング時の再接続
|
|
201
|
-
// ストロボ対策)。すぐ切れた接続では backoff がリセットされず指数バックオフが
|
|
202
|
-
// 効き続けるため、1〜2 秒間隔の再接続ループに陥らない。
|
|
203
|
-
this._armStableReset()
|
|
204
202
|
// open 自体が生存の証拠。直前接続の古い lastRecvAt で開幕直後に zombie 誤検知
|
|
205
|
-
//
|
|
203
|
+
// しないようリセットする。⚠️ _armStableReset() の armedAt 捕捉より「前」に設定すること。
|
|
204
|
+
// ack ベース判定 (lastRecvAt > armedAt) を初期状態で偽にするため (順序を逆にすると
|
|
205
|
+
// armedAt < lastRecvAt となり「inbound 受信済み」と誤判定し、half-open でも backoff を
|
|
206
|
+
// リセットしてしまう)。
|
|
206
207
|
this.lastRecvAt = Date.now()
|
|
207
208
|
this.lastTickAt = Date.now()
|
|
209
|
+
// backoff の即時リセットはしない。接続が STABLE_CONNECTION_MS 維持 + inbound 受信できてから
|
|
210
|
+
// _armStableReset() でリセットする (open→即 close フラッピング時の再接続ストロボ対策)。
|
|
211
|
+
// すぐ切れた / half-open の接続では backoff がリセットされず指数バックオフが効き続けるため、
|
|
212
|
+
// 1〜2 秒間隔の再接続ループに陥らない。
|
|
213
|
+
this._armStableReset()
|
|
208
214
|
this.logger?.info("ws open")
|
|
209
215
|
// 切断中に積んだ pty.data を flush。hello より先に送ると backend で stream
|
|
210
216
|
// 未登録のため unknown_stream error になる可能性があるが、hello の応答待ち
|
|
@@ -583,11 +589,27 @@ export class WsClient extends EventEmitter {
|
|
|
583
589
|
*/
|
|
584
590
|
_armStableReset() {
|
|
585
591
|
this._clearStableReset()
|
|
592
|
+
// 症状①: backoff リセットを「ack ベース」にする。armedAt(≈open 時刻) を捕捉し、
|
|
593
|
+
// STABLE_CONNECTION_MS 後に「open 以降に inbound を受信したか(lastRecvAt > armedAt)」で
|
|
594
|
+
// 判定する。受信あり = 双方向に健全とみなし backoff/5xx をリセット。受信ゼロ =
|
|
595
|
+
// half-open(send は kernel buffer に書けて成功扱いだが TCP は死)とみなし、75s の zombie
|
|
596
|
+
// 検知を待たずに即 forceReconnect する(再接続後の half-open を 10s で回収し復帰を早める)。
|
|
597
|
+
// _onOpen は lastRecvAt=now を armedAt より前に設定するため、初期状態では
|
|
598
|
+
// lastRecvAt <= armedAt となり「未受信」と正しく判定される。
|
|
599
|
+
const armedAt = Date.now()
|
|
586
600
|
this.stableResetTimer = setTimeout(() => {
|
|
587
601
|
this.stableResetTimer = null
|
|
588
|
-
this.
|
|
589
|
-
|
|
590
|
-
|
|
602
|
+
if (this.lastRecvAt > armedAt) {
|
|
603
|
+
this.backoff = MIN_BACKOFF_MS
|
|
604
|
+
this.lastCloseWas5xx = false
|
|
605
|
+
this.logger?.debug("ws connection stable (inbound received), backoff reset")
|
|
606
|
+
} else {
|
|
607
|
+
this.logger?.warn(
|
|
608
|
+
{ sinceArmedMs: Date.now() - armedAt },
|
|
609
|
+
"ws stable window elapsed with no inbound (half-open suspected), forcing reconnect",
|
|
610
|
+
)
|
|
611
|
+
this._forceReconnect()
|
|
612
|
+
}
|
|
591
613
|
}, STABLE_CONNECTION_MS)
|
|
592
614
|
this.stableResetTimer.unref?.()
|
|
593
615
|
}
|