@cocorograph/hub-agent 0.7.0 → 0.7.2
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 +87 -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) がこの時間継続して観測
|
|
@@ -1169,6 +1179,7 @@ function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge, rea
|
|
|
1169
1179
|
prev.permission_mode !== permissionMode ||
|
|
1170
1180
|
prev.stable !== stable ||
|
|
1171
1181
|
prev.proc_busy !== procBusy ||
|
|
1182
|
+
prev.child_busy !== childBusy ||
|
|
1172
1183
|
prev.stalled !== stalled
|
|
1173
1184
|
) {
|
|
1174
1185
|
lastByName.set(s.session_name, {
|
|
@@ -1177,12 +1188,13 @@ function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge, rea
|
|
|
1177
1188
|
permission_mode: permissionMode,
|
|
1178
1189
|
stable,
|
|
1179
1190
|
proc_busy: procBusy,
|
|
1191
|
+
child_busy: childBusy,
|
|
1180
1192
|
stalled,
|
|
1181
1193
|
})
|
|
1182
|
-
// 計装 (2026-06-19):
|
|
1183
|
-
//
|
|
1184
|
-
//
|
|
1185
|
-
|
|
1194
|
+
// 計装 (2026-06-19, 既定 OFF=2026-06-20): 状態変化時に値を記録する。常時 info はログ肥大の
|
|
1195
|
+
// 主因のため HUB_AGENT_STATE_TRACE=1 のときだけ出す。childBusy/outputActive を分けて出し、
|
|
1196
|
+
// proc 内省と出力フローのどちらが busy を立てたか切り分け可能にする(障害解析用)。
|
|
1197
|
+
if (STATE_TRACE) {
|
|
1186
1198
|
logger?.info(
|
|
1187
1199
|
{
|
|
1188
1200
|
session_name: s.session_name,
|
|
@@ -1197,6 +1209,7 @@ function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge, rea
|
|
|
1197
1209
|
},
|
|
1198
1210
|
"session.state push",
|
|
1199
1211
|
)
|
|
1212
|
+
}
|
|
1200
1213
|
client.send({
|
|
1201
1214
|
type: "session.state",
|
|
1202
1215
|
session_name: s.session_name,
|
|
@@ -1205,9 +1218,28 @@ function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge, rea
|
|
|
1205
1218
|
permission_mode: permissionMode,
|
|
1206
1219
|
stable,
|
|
1207
1220
|
proc_busy: procBusy,
|
|
1221
|
+
// child_busy: outputActive を含まない真の子プロセス busy。frontend の shouldQueue が
|
|
1222
|
+
// 出力残り火 (proc_busy 残存) でアイドルなのにキューへ逃がす誤キュー (症状A) を断つために使う。
|
|
1223
|
+
child_busy: childBusy,
|
|
1208
1224
|
stalled,
|
|
1209
1225
|
})
|
|
1210
1226
|
}
|
|
1227
|
+
// 消滅セッションの GC (perf監査/メモリリーク対策): tmux から消えたセッションの状態 Map を
|
|
1228
|
+
// まとめて解放する。clear() は再接続時(onReopen)のみのため、worktree 案件の生成/破棄で
|
|
1229
|
+
// 各 per-session Map が線形増加し RSS が無入力時も成長していた。tmux が一時的に空応答
|
|
1230
|
+
// (states=[]) のときは全消去しないよう states.length>0 をガードにする。
|
|
1231
|
+
if (states.length > 0) {
|
|
1232
|
+
const liveNames = new Set(states.map((s) => s.session_name))
|
|
1233
|
+
for (const name of [...lastByName.keys()]) {
|
|
1234
|
+
if (liveNames.has(name)) continue
|
|
1235
|
+
invalidateSessionCache(name) // capture/cwd/statusGate/spinnerFreeze/stability (state.mjs)
|
|
1236
|
+
stallTracker.forget(name) // StallTracker.byName
|
|
1237
|
+
readinessTracker?.forget?.(name) // ReadinessTracker.byName
|
|
1238
|
+
lastByName.delete(name)
|
|
1239
|
+
outputFlowByName.delete(name)
|
|
1240
|
+
lastTurnAtByName.delete(name)
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1211
1243
|
}
|
|
1212
1244
|
} catch (err) {
|
|
1213
1245
|
logger?.warn({ err: err.message }, "state loop tick failed")
|
|
@@ -1475,13 +1507,26 @@ async function dispatch(msg, ctx) {
|
|
|
1475
1507
|
}
|
|
1476
1508
|
handleUntrackedPtyData(msg, ctx)
|
|
1477
1509
|
return
|
|
1478
|
-
case "pty.resize":
|
|
1479
|
-
ctx.ptyBridge.resize({
|
|
1510
|
+
case "pty.resize": {
|
|
1511
|
+
const resized = ctx.ptyBridge.resize({
|
|
1480
1512
|
stream_id: msg.stream_id,
|
|
1481
1513
|
cols: msg.cols,
|
|
1482
1514
|
rows: msg.rows,
|
|
1483
1515
|
})
|
|
1516
|
+
// 自己修復 (perf監査 Phase3): stream 不在で resize できなかった (reap 済み等) ときは
|
|
1517
|
+
// pty.error(stream_missing) を返し browser を再 attach させる。keepalive resize が dead
|
|
1518
|
+
// stream を即検知して 4 分単位の「pty.resize but stream missing」churn を自己修復する
|
|
1519
|
+
// (handleTrackedPtyData/handleUntrackedPtyData と同じパターン)。resize 例外 (pty 存在) も
|
|
1520
|
+
// false だが、その場合の再 attach は既存 stream への無害な再接続。
|
|
1521
|
+
if (!resized && msg.stream_id) {
|
|
1522
|
+
ctx.client.send({
|
|
1523
|
+
type: "pty.error",
|
|
1524
|
+
stream_id: msg.stream_id,
|
|
1525
|
+
error: "stream_missing",
|
|
1526
|
+
})
|
|
1527
|
+
}
|
|
1484
1528
|
return
|
|
1529
|
+
}
|
|
1485
1530
|
case "pty.detach":
|
|
1486
1531
|
ctx.ptyBridge.detach({ stream_id: msg.stream_id })
|
|
1487
1532
|
return
|
|
@@ -1692,20 +1737,37 @@ async function dispatch(msg, ctx) {
|
|
|
1692
1737
|
const key = viewName || viewCwd
|
|
1693
1738
|
const prev = ctx.tuiRotationNotified.get(key)
|
|
1694
1739
|
const now = Date.now()
|
|
1695
|
-
const
|
|
1740
|
+
const prevNewId =
|
|
1741
|
+
prev && typeof prev === "object" ? prev.newId : prev
|
|
1742
|
+
const prevCount =
|
|
1743
|
+
prev && typeof prev === "object" ? (prev.count ?? 0) : 0
|
|
1744
|
+
const { rotated, newSessionId, capped } = decideSessionRotation({
|
|
1696
1745
|
viewingSessionId: viewSid,
|
|
1697
1746
|
newestSessionId: newestId,
|
|
1698
|
-
// 旧形式 (文字列) と新形式 ({newId, ts}) の両対応。
|
|
1699
|
-
lastNotifiedNewId:
|
|
1700
|
-
prev && typeof prev === "object" ? prev.newId : prev,
|
|
1747
|
+
// 旧形式 (文字列) と新形式 ({newId, ts, count}) の両対応。
|
|
1748
|
+
lastNotifiedNewId: prevNewId,
|
|
1701
1749
|
lastNotifiedAt:
|
|
1702
1750
|
prev && typeof prev === "object" ? prev.ts : null,
|
|
1751
|
+
lastNotifiedCount: prevCount,
|
|
1703
1752
|
now,
|
|
1704
1753
|
})
|
|
1754
|
+
if (capped) {
|
|
1755
|
+
// 安全弁発火: フロントが採用しないまま maxReNotify 回再通知済み。これ以上の
|
|
1756
|
+
// rotated 連発を止め、無限ピンポンを断つ (フロント側 cwd 必須化で通常は到達しない)。
|
|
1757
|
+
logger.warn(
|
|
1758
|
+
{ session: viewName, cwd: viewCwd, view: viewSid, newest: newestId },
|
|
1759
|
+
"tui session rotation: capped (frontend not adopting; stopped re-notify)",
|
|
1760
|
+
)
|
|
1761
|
+
return
|
|
1762
|
+
}
|
|
1705
1763
|
if (!rotated) return
|
|
1706
|
-
// {newId, ts}
|
|
1707
|
-
//
|
|
1708
|
-
ctx.tuiRotationNotified.set(key, {
|
|
1764
|
+
// {newId, ts, count} で保存。同一 new への再通知をスロットリング+回数キャップする。
|
|
1765
|
+
// newId が変われば count を 1 にリセット、同一なら加算する。
|
|
1766
|
+
ctx.tuiRotationNotified.set(key, {
|
|
1767
|
+
newId: newSessionId,
|
|
1768
|
+
ts: now,
|
|
1769
|
+
count: newSessionId === prevNewId ? prevCount + 1 : 1,
|
|
1770
|
+
})
|
|
1709
1771
|
// 冪等マップも新 id へ更新し、将来の remount bind が旧 id へ
|
|
1710
1772
|
// respawn (claude 再起動) してしまうのを防ぐ。
|
|
1711
1773
|
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
|
/**
|