@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 +1 -1
- package/src/dispatch-scheduler.mjs +238 -0
- package/src/main.mjs +26 -44
package/package.json
CHANGED
|
@@ -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
|
|
1107
|
-
//
|
|
1108
|
-
//
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
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
|
|