@cocorograph/hub-agent 0.8.4 → 0.8.5

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.8.4",
3
+ "version": "0.8.5",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Hub → agent の inbound dispatch を、接続世代と機能レーン単位で直列化する。
3
+ *
4
+ * 旧実装は全 message を 1 本の Promise chain に載せていたため、ある cwd の重い
5
+ * tmux/codex 処理が、無関係な cwd の claude.attach や reconnect の ready/sync まで
6
+ * 数分止める head-of-line blocking を起こしていた。
7
+ *
8
+ * 不変条件:
9
+ * - 同一 cwd・同一 CLI の chat 操作は受信順を維持する。
10
+ * - tmux/control と Claude/Codex、別 cwd 同士は互いに待たない。
11
+ * - ready / streams sync 等の接続ライフサイクルはキューを待たない。
12
+ * - WS 世代が変わったら、旧世代で未実行の操作を適用しない。
13
+ * - detach 済み stream の未実行 attach を適用しない。
14
+ */
15
+
16
+ const LIFECYCLE_TYPES = new Set([
17
+ "ready",
18
+ "error",
19
+ "agent.streams.sync.response",
20
+ "bundle.sync.request",
21
+ "agent.shutdown",
22
+ ])
23
+
24
+ const MAX_STREAM_LANE_KEYS = 4096
25
+
26
+ export function isConnectionLifecycleMessage(type) {
27
+ return LIFECYCLE_TYPES.has(type)
28
+ }
29
+
30
+ function chatMode(type) {
31
+ if (typeof type !== "string") return null
32
+ if (type === "claude.attach" || type.startsWith("claude.")) return "claude"
33
+ if (type === "codex.attach" || type.startsWith("codex.")) return "codex"
34
+ return null
35
+ }
36
+
37
+ function globalMutationMode(type) {
38
+ if (type === "profile.switch" || type === "profile.add" || type === "profile.rename") {
39
+ return "claude"
40
+ }
41
+ if (
42
+ type === "codex.profile.switch" ||
43
+ type === "codex.profile.add" ||
44
+ type === "codex.profile.rename"
45
+ ) {
46
+ return "codex"
47
+ }
48
+ return null
49
+ }
50
+
51
+ function attachLaneKey(mode, msg) {
52
+ const cwd = typeof msg?.cwd === "string" ? msg.cwd.trim() : ""
53
+ if (cwd) return `${mode}:cwd:${cwd}`
54
+ const resumeId =
55
+ mode === "claude"
56
+ ? msg?.resume_session_id
57
+ : msg?.resume_thread_id || msg?.thread_id
58
+ if (typeof resumeId === "string" && resumeId) return `${mode}:resume:${resumeId}`
59
+ const streamId = typeof msg?.stream_id === "string" ? msg.stream_id : ""
60
+ return streamId ? `${mode}:stream:${streamId}` : `${mode}:control`
61
+ }
62
+
63
+ /**
64
+ * @param {{
65
+ * run: (msg: object) => Promise<void>|void,
66
+ * logger?: {debug?: Function, warn?: Function, error?: Function},
67
+ * shouldBypass?: (type: string|undefined) => boolean,
68
+ * }} options
69
+ */
70
+ export class DispatchScheduler {
71
+ constructor({ run, logger, shouldBypass }) {
72
+ if (typeof run !== "function") throw new TypeError("DispatchScheduler run is required")
73
+ this.run = run
74
+ this.logger = logger
75
+ this.shouldBypass = typeof shouldBypass === "function" ? shouldBypass : () => false
76
+ this.generation = 0
77
+ this.sequence = 0
78
+ this.laneTails = new Map()
79
+ this.streamLaneKeys = new Map()
80
+ this.detachedAtByStream = new Map()
81
+ }
82
+
83
+ /**
84
+ * WS close/open で世代を進める。既に走り始めた処理は止められないが、旧 tail を
85
+ * 新接続へ継承せず、まだ始まっていない旧世代 task は実行直前に drop する。
86
+ */
87
+ advanceGeneration(reason = "connection_changed") {
88
+ this.generation += 1
89
+ this.laneTails.clear()
90
+ this.streamLaneKeys.clear()
91
+ this.detachedAtByStream.clear()
92
+ this.logger?.debug?.(
93
+ { generation: this.generation, reason },
94
+ "dispatch generation advanced",
95
+ )
96
+ return this.generation
97
+ }
98
+
99
+ _rememberStreamLane(streamId, laneKey) {
100
+ const mode = laneKey.startsWith("codex:") ? "codex" : "claude"
101
+ const streamKey = streamId ? `${mode}:${streamId}` : ""
102
+ if (!streamKey) return
103
+ this.streamLaneKeys.set(streamKey, laneKey)
104
+ while (this.streamLaneKeys.size > MAX_STREAM_LANE_KEYS) {
105
+ const oldest = this.streamLaneKeys.keys().next().value
106
+ if (oldest == null) break
107
+ this.streamLaneKeys.delete(oldest)
108
+ this.detachedAtByStream.delete(oldest)
109
+ }
110
+ }
111
+
112
+ _rememberDetach(streamKey, sequence) {
113
+ if (!streamKey) return
114
+ this.detachedAtByStream.set(streamKey, sequence)
115
+ while (this.detachedAtByStream.size > MAX_STREAM_LANE_KEYS) {
116
+ const oldest = this.detachedAtByStream.keys().next().value
117
+ if (oldest == null) break
118
+ this.detachedAtByStream.delete(oldest)
119
+ }
120
+ }
121
+
122
+ _laneFor(msg) {
123
+ const type = msg?.type
124
+ const mutationMode = globalMutationMode(type)
125
+ if (mutationMode) return `${mutationMode}:global`
126
+
127
+ const mode = chatMode(type)
128
+ if (!mode) return "control"
129
+
130
+ const streamId = typeof msg?.stream_id === "string" ? msg.stream_id : ""
131
+ const streamKey = streamId ? `${mode}:${streamId}` : ""
132
+ if (type === `${mode}.attach`) {
133
+ const laneKey = attachLaneKey(mode, msg)
134
+ this._rememberStreamLane(streamId, laneKey)
135
+ return laneKey
136
+ }
137
+ if (streamKey && this.streamLaneKeys.has(streamKey)) {
138
+ return this.streamLaneKeys.get(streamKey)
139
+ }
140
+ const cwd = typeof msg?.cwd === "string" ? msg.cwd.trim() : ""
141
+ if (cwd) return `${mode}:cwd:${cwd}`
142
+ if (streamId) return `${mode}:stream:${streamId}`
143
+ // codex.profile.list 等の stream 非依存 read は main 側の read-only bypass
144
+ // に分類する。未知の stream 非依存メッセージは mode-wide control として扱う。
145
+ return `${mode}:global`
146
+ }
147
+
148
+ _previousFor(lane) {
149
+ const own = this.laneTails.get(lane)
150
+ const match = /^(claude|codex):(global|cwd:|resume:|stream:)/.exec(lane)
151
+ if (!match) return own || Promise.resolve()
152
+
153
+ const mode = match[1]
154
+ if (lane === `${mode}:global`) {
155
+ // プロファイル切替等の mode-wide mutation は、既に走っている同 CLI の
156
+ // attach/input が終わってから適用する。
157
+ const dependencies = []
158
+ for (const [key, tail] of this.laneTails) {
159
+ if (key.startsWith(`${mode}:`)) dependencies.push(tail)
160
+ }
161
+ return dependencies.length > 0 ? Promise.all(dependencies) : Promise.resolve()
162
+ }
163
+
164
+ // cwd/stream lane は、先行する profile mutation があればその完了も待つ。
165
+ const globalTail = this.laneTails.get(`${mode}:global`)
166
+ if (own && globalTail && own !== globalTail) return Promise.all([own, globalTail])
167
+ return own || globalTail || Promise.resolve()
168
+ }
169
+
170
+ _runNow(msg, lane = "bypass") {
171
+ return Promise.resolve().then(() => this.run(msg)).catch((err) => {
172
+ this.logger?.error?.(
173
+ { err: err?.message, type: msg?.type, lane },
174
+ "dispatch threw",
175
+ )
176
+ })
177
+ }
178
+
179
+ enqueue(msg) {
180
+ const type = msg?.type
181
+ const sequence = ++this.sequence
182
+ if (this.shouldBypass(type) || isConnectionLifecycleMessage(type)) {
183
+ return this._runNow(msg)
184
+ }
185
+
186
+ const streamId = typeof msg?.stream_id === "string" ? msg.stream_id : ""
187
+ const mode = chatMode(type)
188
+ const streamKey = mode && streamId ? `${mode}:${streamId}` : ""
189
+ const isDetach = type === "claude.detach" || type === "codex.detach"
190
+ if (isDetach && streamKey) {
191
+ // attach がまだ lane 内で待機中でも、その実行前に stale と判定できるよう
192
+ // detach の dispatch 完了を待たず受信時点の順序を記録する。真偽値だけだと
193
+ // detach より後に来た正規の再 attach まで落とすため、sequence で前後を区別する。
194
+ this._rememberDetach(streamKey, sequence)
195
+ }
196
+
197
+ const lane = this._laneFor(msg)
198
+ const generation = this.generation
199
+ const previous = this._previousFor(lane)
200
+ const task = previous
201
+ .then(async () => {
202
+ if (generation !== this.generation) {
203
+ this.logger?.debug?.(
204
+ { type, lane, queued_generation: generation, generation: this.generation },
205
+ "stale queued dispatch dropped after reconnect",
206
+ )
207
+ return
208
+ }
209
+ if (
210
+ (type === "claude.attach" || type === "codex.attach") &&
211
+ streamKey &&
212
+ (this.detachedAtByStream.get(streamKey) ?? -1) > sequence
213
+ ) {
214
+ this.logger?.debug?.(
215
+ { type, lane, stream_id: streamId },
216
+ "detached queued attach dropped",
217
+ )
218
+ return
219
+ }
220
+ await this.run(msg)
221
+ })
222
+ .catch((err) => {
223
+ this.logger?.error?.(
224
+ { err: err?.message, type, lane },
225
+ "dispatch threw in serial lane",
226
+ )
227
+ })
228
+ .finally(() => {
229
+ if (this.laneTails.get(lane) === task) this.laneTails.delete(lane)
230
+ if (isDetach && streamKey && generation === this.generation) {
231
+ this.streamLaneKeys.delete(streamKey)
232
+ }
233
+ })
234
+
235
+ this.laneTails.set(lane, task)
236
+ return task
237
+ }
238
+ }
package/src/main.mjs CHANGED
@@ -47,6 +47,7 @@ import {
47
47
  } from "./hub-bundle.mjs"
48
48
  import { loadPlugins, runHookBroadcast, runHookChain } from "./plugin-loader.mjs"
49
49
  import { WsClient } from "./ws-client.mjs"
50
+ import { DispatchScheduler } from "./dispatch-scheduler.mjs"
50
51
  import { PtyBridge } from "./pty-bridge.mjs"
51
52
  import { ClaudeStreamBridge } from "./claude-stream-bridge.mjs"
52
53
  import { CodexMcpControl } from "./codex-mcp-control.mjs"
@@ -337,6 +338,8 @@ export function isReadOnlyRpc(type) {
337
338
  type === "claude.history.request" ||
338
339
  type === "claude.sessions.request" ||
339
340
  type === "codex.sessions.request" ||
341
+ type === "profile.list" ||
342
+ type === "codex.profile.list" ||
340
343
  // T06603 (2026-07-25): キュー snapshot の pull。in-memory 配列を読むだけで副作用が無く、
341
344
  // かつ「直列キュー前方の重い処理で再同期が遅延する」と送信待ちチップの固着が長引く
342
345
  // (再同期は固着を直すための経路なので、詰まりやすい経路に置いてはならない)。
@@ -1088,7 +1091,7 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
1088
1091
  })
1089
1092
  }
1090
1093
 
1091
- // Hub からのメッセージ dispatch は **直列実行** する。
1094
+ // Hub からのメッセージ dispatch は、接続世代 + 機能レーン単位で直列実行する。
1092
1095
  //
1093
1096
  // `EventEmitter.emit` は async リスナーの完了を待たないため、`async (msg) =>
1094
1097
  // await dispatch(msg, ...)` の形だと複数 WS メッセージが連続して届いたとき
@@ -1103,50 +1106,29 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
1103
1106
  // 結果として paste が tmux 内部状態 (copy mode 等) を抜ける前に届き、
1104
1107
  // claude TUI に正しく入らない事象が散発する。
1105
1108
  //
1106
- // Promise chain で前の dispatch 完了を待ってから次を始めることで、WS
1107
- // 受信順 = pane 反映順を保証する。pty 出力 (agent → browser) は別経路
1108
- // (`ptyBridge.on("output")`) なのでこの直列化の影響を受けない。
1109
- let dispatchChain = Promise.resolve()
1110
- client.on("message", (msg) => {
1111
- // B7: pty 出力データ (pty.data) と resize (pty.resize) は順序保証が不要で高頻度な
1112
- // 低レイテンシ経路。これらを直列キュー (dispatchChain) に通すと、1 件の tmux.exec 等
1113
- // のハングで pty 入出力まで全停止してしまう。安全側に「pty 出力データと resize のみ」
1114
- // を直列キューからバイパスして即時処理する。入力系 (claude.input)・制御系 (tmux.exec /
1115
- // permission / cancel→paste 等) は WS 受信順 = pane 反映順を守るため dispatchChain に残す。
1116
- if (isFastPathMessage(msg?.type)) {
1117
- Promise.resolve(
1118
- dispatch(msg, { ...ctx, client, ptyBridge, claudeBridge, codexBridge, codexMcpControl, uploadManager }),
1119
- ).catch((err) => {
1120
- logger.error(
1121
- { err: err.message, type: msg?.type },
1122
- "dispatch threw (bypassed pty fast-path)",
1123
- )
1124
- })
1125
- return
1126
- }
1127
- // Phase 3 (Read-plane): 読み取り専用 RPC (history/sessions) も直列キューから外して並行処理する。
1128
- // 直列キュー前方の重い処理 (codex app-server spawn 等) が claude.attach→ready を HoL blocking
1129
- // するのを防ぐ (view/spawn 分離)。純粋読み取りで順序依存が無いことは isReadOnlyRpc の説明参照。
1130
- if (isReadOnlyRpc(msg?.type)) {
1131
- Promise.resolve(
1132
- dispatch(msg, { ...ctx, client, ptyBridge, claudeBridge, codexBridge, codexMcpControl, uploadManager }),
1133
- ).catch((err) => {
1134
- logger.error(
1135
- { err: err.message, type: msg?.type },
1136
- "dispatch threw (bypassed read-only RPC lane)",
1137
- )
1138
- })
1139
- return
1140
- }
1141
- dispatchChain = dispatchChain
1142
- .then(() => dispatch(msg, { ...ctx, client, ptyBridge, claudeBridge, codexBridge, codexMcpControl, uploadManager }))
1143
- .catch((err) => {
1144
- logger.error(
1145
- { err: err.message, type: msg?.type },
1146
- "dispatch threw in serial queue",
1147
- )
1148
- })
1109
+ // 旧来の global Promise chain は、ある cwd の tmux/codex 処理が別 cwd の
1110
+ // claude.attach と reconnect ready/sync まで 60〜203 秒止める HoL blocking を
1111
+ // 本番で起こした。DispatchScheduler は同一 cwd・同一 CLI の順序だけを維持し、
1112
+ // tmux/control・Claude・Codex・別 cwd を独立レーンへ分離する。
1113
+ const dispatchCtx = {
1114
+ ...ctx,
1115
+ client,
1116
+ ptyBridge,
1117
+ claudeBridge,
1118
+ codexBridge,
1119
+ codexMcpControl,
1120
+ uploadManager,
1121
+ }
1122
+ const dispatchScheduler = new DispatchScheduler({
1123
+ logger,
1124
+ run: (msg) => dispatch(msg, dispatchCtx),
1125
+ // pty fast-path / read-only RPC は従来どおり即時実行。接続 lifecycle は
1126
+ // scheduler 自身が常に priority 扱いする。
1127
+ shouldBypass: (type) => isFastPathMessage(type) || isReadOnlyRpc(type),
1149
1128
  })
1129
+ client.on("close", () => dispatchScheduler.advanceGeneration("ws_close"))
1130
+ client.on("open", () => dispatchScheduler.advanceGeneration("ws_open"))
1131
+ client.on("message", (msg) => dispatchScheduler.enqueue(msg))
1150
1132
 
1151
1133
  client.connect()
1152
1134