@cocorograph/hub-agent 0.7.0 → 0.7.1
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/claude-history.mjs +9 -0
- package/src/main.mjs +82 -25
- package/src/state.mjs +3 -0
package/package.json
CHANGED
package/src/claude-history.mjs
CHANGED
|
@@ -321,13 +321,22 @@ export function decideSessionRotation({
|
|
|
321
321
|
newestSessionId,
|
|
322
322
|
lastNotifiedNewId,
|
|
323
323
|
lastNotifiedAt,
|
|
324
|
+
lastNotifiedCount = 0,
|
|
324
325
|
now,
|
|
325
326
|
reNotifyMs = 4000,
|
|
327
|
+
maxReNotify = 5,
|
|
326
328
|
} = {}) {
|
|
327
329
|
if (!newestSessionId || !viewingSessionId) return { rotated: false }
|
|
328
330
|
if (newestSessionId === viewingSessionId) return { rotated: false }
|
|
329
331
|
if (newestSessionId === lastNotifiedNewId) {
|
|
330
332
|
// 同一 new。時刻情報が揃っていて再通知間隔を超えていれば、固着救済のため再通知する。
|
|
333
|
+
// ただし安全弁: 同一 (viewing→newest) ペアを maxReNotify 回再通知してもフロントが
|
|
334
|
+
// 採用しない場合は打ち切る (capped)。フロントが viewing を更新しない=外来 id 汚染や
|
|
335
|
+
// 別 cwd の rotated 誤採用の兆候で、これを無制限に再通知すると rotated 無限ピンポンに
|
|
336
|
+
// なる (perf監査: cockpit↔cocomiru で rotated 244件/h を実測)。
|
|
337
|
+
if (lastNotifiedCount >= maxReNotify) {
|
|
338
|
+
return { rotated: false, capped: true }
|
|
339
|
+
}
|
|
331
340
|
if (
|
|
332
341
|
typeof now === "number" &&
|
|
333
342
|
typeof lastNotifiedAt === "number" &&
|
package/src/main.mjs
CHANGED
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
capturePane,
|
|
38
38
|
detectPermissionModeFromText,
|
|
39
39
|
detectSessionState,
|
|
40
|
+
invalidateSessionCache,
|
|
40
41
|
listSessionStates,
|
|
41
42
|
StallTracker,
|
|
42
43
|
} from "./state.mjs"
|
|
@@ -895,17 +896,18 @@ async function startSessionEventWatcher({ client, logger, readinessTracker }) {
|
|
|
895
896
|
readinessTracker.arm(sessionName)
|
|
896
897
|
}
|
|
897
898
|
}
|
|
898
|
-
// 計装 (2026-06-19): ターン境界イベントと arm
|
|
899
|
-
//
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
899
|
+
// 計装 (2026-06-19, 既定 OFF=2026-06-20): ターン境界イベントと arm を記録する。常時 info は
|
|
900
|
+
// ログ肥大の主因のため HUB_AGENT_STATE_TRACE=1 のときだけ出す(障害解析時に有効化)。
|
|
901
|
+
if (STATE_TRACE) {
|
|
902
|
+
logger?.info(
|
|
903
|
+
{
|
|
904
|
+
session_name: sessionName,
|
|
905
|
+
event: data.event,
|
|
906
|
+
armed: readinessTracker ? readinessTracker.isArmed(sessionName) : undefined,
|
|
907
|
+
},
|
|
908
|
+
"session.event push",
|
|
909
|
+
)
|
|
910
|
+
}
|
|
909
911
|
client.send({
|
|
910
912
|
type: "session.event",
|
|
911
913
|
session_name: sessionName,
|
|
@@ -1041,7 +1043,15 @@ function startReadinessLoop({ tracker, logger, intervalMs = 700 }) {
|
|
|
1041
1043
|
// いれば「出力ストリーミング中」とみなし proc_busy に OR する。state loop は 5s 周期なので、
|
|
1042
1044
|
// 連続生成中は毎 tick 署名が変わり常時 true、出力が止まれば次 tick (≤5s) で署名不変になり
|
|
1043
1045
|
// 窓経過後に false へ落ちる (ターン終了後のスピナー固着=症状A を悪化させない短さ)。
|
|
1044
|
-
|
|
1046
|
+
// 出力フロー検知窓。state loop tick(5000ms)の整数倍にして、出力が止まった後の outputActive が
|
|
1047
|
+
// 5s tick 境界で true↔false フラッピングするのを防ぐ(8000 は非整数倍で proc_busy 単独反転 push が
|
|
1048
|
+
// 多発していた=perf監査。10000=2tick で安定側、影響は『出力停止とみなす猶予 8s→10s』のみ)。
|
|
1049
|
+
const OUTPUT_ACTIVE_MS = Number(process.env.HUB_AGENT_OUTPUT_ACTIVE_MS ?? 10000)
|
|
1050
|
+
|
|
1051
|
+
// 状態信号の計装ログ(session.state/event push)を出すか。既定 OFF。障害解析時に
|
|
1052
|
+
// HUB_AGENT_STATE_TRACE=1 で一時的に有効化する(常時 info 出力はログ肥大の主因=perf監査で
|
|
1053
|
+
// 直近1h ログの約半分を占めると実測。挙動には一切影響しない純ログ)。
|
|
1054
|
+
const STATE_TRACE = process.env.HUB_AGENT_STATE_TRACE === "1"
|
|
1045
1055
|
|
|
1046
1056
|
// 権威的な「応答停止 (ハング)」判定の無進捗しきい値。ターン進行中 (armed) なのに、実際の
|
|
1047
1057
|
// 進捗 (子プロセス実行中 / 出力フロー / ペインのライブ processing) がこの時間継続して観測
|
|
@@ -1179,10 +1189,10 @@ function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge, rea
|
|
|
1179
1189
|
proc_busy: procBusy,
|
|
1180
1190
|
stalled,
|
|
1181
1191
|
})
|
|
1182
|
-
// 計装 (2026-06-19):
|
|
1183
|
-
//
|
|
1184
|
-
//
|
|
1185
|
-
|
|
1192
|
+
// 計装 (2026-06-19, 既定 OFF=2026-06-20): 状態変化時に値を記録する。常時 info はログ肥大の
|
|
1193
|
+
// 主因のため HUB_AGENT_STATE_TRACE=1 のときだけ出す。childBusy/outputActive を分けて出し、
|
|
1194
|
+
// proc 内省と出力フローのどちらが busy を立てたか切り分け可能にする(障害解析用)。
|
|
1195
|
+
if (STATE_TRACE) {
|
|
1186
1196
|
logger?.info(
|
|
1187
1197
|
{
|
|
1188
1198
|
session_name: s.session_name,
|
|
@@ -1197,6 +1207,7 @@ function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge, rea
|
|
|
1197
1207
|
},
|
|
1198
1208
|
"session.state push",
|
|
1199
1209
|
)
|
|
1210
|
+
}
|
|
1200
1211
|
client.send({
|
|
1201
1212
|
type: "session.state",
|
|
1202
1213
|
session_name: s.session_name,
|
|
@@ -1208,6 +1219,22 @@ function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge, rea
|
|
|
1208
1219
|
stalled,
|
|
1209
1220
|
})
|
|
1210
1221
|
}
|
|
1222
|
+
// 消滅セッションの GC (perf監査/メモリリーク対策): tmux から消えたセッションの状態 Map を
|
|
1223
|
+
// まとめて解放する。clear() は再接続時(onReopen)のみのため、worktree 案件の生成/破棄で
|
|
1224
|
+
// 各 per-session Map が線形増加し RSS が無入力時も成長していた。tmux が一時的に空応答
|
|
1225
|
+
// (states=[]) のときは全消去しないよう states.length>0 をガードにする。
|
|
1226
|
+
if (states.length > 0) {
|
|
1227
|
+
const liveNames = new Set(states.map((s) => s.session_name))
|
|
1228
|
+
for (const name of [...lastByName.keys()]) {
|
|
1229
|
+
if (liveNames.has(name)) continue
|
|
1230
|
+
invalidateSessionCache(name) // capture/cwd/statusGate/spinnerFreeze/stability (state.mjs)
|
|
1231
|
+
stallTracker.forget(name) // StallTracker.byName
|
|
1232
|
+
readinessTracker?.forget?.(name) // ReadinessTracker.byName
|
|
1233
|
+
lastByName.delete(name)
|
|
1234
|
+
outputFlowByName.delete(name)
|
|
1235
|
+
lastTurnAtByName.delete(name)
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1211
1238
|
}
|
|
1212
1239
|
} catch (err) {
|
|
1213
1240
|
logger?.warn({ err: err.message }, "state loop tick failed")
|
|
@@ -1475,13 +1502,26 @@ async function dispatch(msg, ctx) {
|
|
|
1475
1502
|
}
|
|
1476
1503
|
handleUntrackedPtyData(msg, ctx)
|
|
1477
1504
|
return
|
|
1478
|
-
case "pty.resize":
|
|
1479
|
-
ctx.ptyBridge.resize({
|
|
1505
|
+
case "pty.resize": {
|
|
1506
|
+
const resized = ctx.ptyBridge.resize({
|
|
1480
1507
|
stream_id: msg.stream_id,
|
|
1481
1508
|
cols: msg.cols,
|
|
1482
1509
|
rows: msg.rows,
|
|
1483
1510
|
})
|
|
1511
|
+
// 自己修復 (perf監査 Phase3): stream 不在で resize できなかった (reap 済み等) ときは
|
|
1512
|
+
// pty.error(stream_missing) を返し browser を再 attach させる。keepalive resize が dead
|
|
1513
|
+
// stream を即検知して 4 分単位の「pty.resize but stream missing」churn を自己修復する
|
|
1514
|
+
// (handleTrackedPtyData/handleUntrackedPtyData と同じパターン)。resize 例外 (pty 存在) も
|
|
1515
|
+
// false だが、その場合の再 attach は既存 stream への無害な再接続。
|
|
1516
|
+
if (!resized && msg.stream_id) {
|
|
1517
|
+
ctx.client.send({
|
|
1518
|
+
type: "pty.error",
|
|
1519
|
+
stream_id: msg.stream_id,
|
|
1520
|
+
error: "stream_missing",
|
|
1521
|
+
})
|
|
1522
|
+
}
|
|
1484
1523
|
return
|
|
1524
|
+
}
|
|
1485
1525
|
case "pty.detach":
|
|
1486
1526
|
ctx.ptyBridge.detach({ stream_id: msg.stream_id })
|
|
1487
1527
|
return
|
|
@@ -1692,20 +1732,37 @@ async function dispatch(msg, ctx) {
|
|
|
1692
1732
|
const key = viewName || viewCwd
|
|
1693
1733
|
const prev = ctx.tuiRotationNotified.get(key)
|
|
1694
1734
|
const now = Date.now()
|
|
1695
|
-
const
|
|
1735
|
+
const prevNewId =
|
|
1736
|
+
prev && typeof prev === "object" ? prev.newId : prev
|
|
1737
|
+
const prevCount =
|
|
1738
|
+
prev && typeof prev === "object" ? (prev.count ?? 0) : 0
|
|
1739
|
+
const { rotated, newSessionId, capped } = decideSessionRotation({
|
|
1696
1740
|
viewingSessionId: viewSid,
|
|
1697
1741
|
newestSessionId: newestId,
|
|
1698
|
-
// 旧形式 (文字列) と新形式 ({newId, ts}) の両対応。
|
|
1699
|
-
lastNotifiedNewId:
|
|
1700
|
-
prev && typeof prev === "object" ? prev.newId : prev,
|
|
1742
|
+
// 旧形式 (文字列) と新形式 ({newId, ts, count}) の両対応。
|
|
1743
|
+
lastNotifiedNewId: prevNewId,
|
|
1701
1744
|
lastNotifiedAt:
|
|
1702
1745
|
prev && typeof prev === "object" ? prev.ts : null,
|
|
1746
|
+
lastNotifiedCount: prevCount,
|
|
1703
1747
|
now,
|
|
1704
1748
|
})
|
|
1749
|
+
if (capped) {
|
|
1750
|
+
// 安全弁発火: フロントが採用しないまま maxReNotify 回再通知済み。これ以上の
|
|
1751
|
+
// rotated 連発を止め、無限ピンポンを断つ (フロント側 cwd 必須化で通常は到達しない)。
|
|
1752
|
+
logger.warn(
|
|
1753
|
+
{ session: viewName, cwd: viewCwd, view: viewSid, newest: newestId },
|
|
1754
|
+
"tui session rotation: capped (frontend not adopting; stopped re-notify)",
|
|
1755
|
+
)
|
|
1756
|
+
return
|
|
1757
|
+
}
|
|
1705
1758
|
if (!rotated) return
|
|
1706
|
-
// {newId, ts}
|
|
1707
|
-
//
|
|
1708
|
-
ctx.tuiRotationNotified.set(key, {
|
|
1759
|
+
// {newId, ts, count} で保存。同一 new への再通知をスロットリング+回数キャップする。
|
|
1760
|
+
// newId が変われば count を 1 にリセット、同一なら加算する。
|
|
1761
|
+
ctx.tuiRotationNotified.set(key, {
|
|
1762
|
+
newId: newSessionId,
|
|
1763
|
+
ts: now,
|
|
1764
|
+
count: newSessionId === prevNewId ? prevCount + 1 : 1,
|
|
1765
|
+
})
|
|
1709
1766
|
// 冪等マップも新 id へ更新し、将来の remount bind が旧 id へ
|
|
1710
1767
|
// respawn (claude 再起動) してしまうのを防ぐ。
|
|
1711
1768
|
if (viewName) ctx.tuiReboundSessions.set(viewName, newSessionId)
|
package/src/state.mjs
CHANGED
|
@@ -47,12 +47,15 @@ export function invalidateSessionCache(sessionName) {
|
|
|
47
47
|
_cwdCache.clear()
|
|
48
48
|
_statusGate.clear()
|
|
49
49
|
_spinnerFreezeByName.clear()
|
|
50
|
+
_stabilityByName.clear()
|
|
50
51
|
return
|
|
51
52
|
}
|
|
52
53
|
_captureCache.delete(sessionName)
|
|
53
54
|
_cwdCache.delete(sessionName)
|
|
54
55
|
_statusGate.delete(sessionName)
|
|
55
56
|
_spinnerFreezeByName.delete(sessionName)
|
|
57
|
+
// _stabilityByName も per-session で消す (消滅セッションの GC 漏れ=メモリリーク対策, perf監査)。
|
|
58
|
+
_stabilityByName.delete(sessionName)
|
|
56
59
|
}
|
|
57
60
|
|
|
58
61
|
/**
|