@cocorograph/hub-agent 0.8.4 → 0.8.6
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/async-ttl-cache.mjs +51 -0
- package/src/codex-stream-bridge.mjs +38 -11
- package/src/dispatch-scheduler.mjs +238 -0
- package/src/main.mjs +80 -82
- package/src/tmux.mjs +16 -4
package/package.json
CHANGED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 読み取り専用スナップショット向けの短期 TTL + single-flight キャッシュ。
|
|
3
|
+
*
|
|
4
|
+
* - TTL 内は直前の成功値を即返す
|
|
5
|
+
* - TTL 切れ後に複数 caller が重なった場合も build は 1 本だけ実行する
|
|
6
|
+
* - build 失敗はキャッシュせず、次回 caller が再試行できる
|
|
7
|
+
* - clear() は進行中 build の呼び出し元も新世代の build へ合流し直す
|
|
8
|
+
*/
|
|
9
|
+
export class AsyncTtlCache {
|
|
10
|
+
constructor({ ttlMs, now = () => Date.now() }) {
|
|
11
|
+
this.ttlMs = Math.max(0, Number(ttlMs) || 0)
|
|
12
|
+
this.now = now
|
|
13
|
+
this.hasValue = false
|
|
14
|
+
this.value = undefined
|
|
15
|
+
this.expiresAt = 0
|
|
16
|
+
this.inFlight = null
|
|
17
|
+
this.generation = 0
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async get(build) {
|
|
21
|
+
if (this.hasValue && this.now() < this.expiresAt) return this.value
|
|
22
|
+
if (this.inFlight) return this.inFlight
|
|
23
|
+
|
|
24
|
+
const generation = this.generation
|
|
25
|
+
const pending = Promise.resolve().then(build)
|
|
26
|
+
this.inFlight = pending
|
|
27
|
+
try {
|
|
28
|
+
const value = await pending
|
|
29
|
+
// build 中に mutation / state push が走った場合、旧値を最初の caller にも返さない。
|
|
30
|
+
// clear() が inFlight を外しているため、新世代の build を開始するか既存の新 build に
|
|
31
|
+
// 合流する。read-only snapshot 専用なので再実行は安全。
|
|
32
|
+
if (this.generation !== generation) return this.get(build)
|
|
33
|
+
this.value = value
|
|
34
|
+
this.hasValue = true
|
|
35
|
+
this.expiresAt = this.now() + this.ttlMs
|
|
36
|
+
return value
|
|
37
|
+
} finally {
|
|
38
|
+
if (this.inFlight === pending) this.inFlight = null
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
clear() {
|
|
43
|
+
this.generation += 1
|
|
44
|
+
this.hasValue = false
|
|
45
|
+
this.value = undefined
|
|
46
|
+
this.expiresAt = 0
|
|
47
|
+
// 旧 build 自体は安全に完走させるが、新 caller はそこへ合流させない。旧 build の
|
|
48
|
+
// finally は identity 比較により、新世代の inFlight を誤って消さない。
|
|
49
|
+
this.inFlight = null
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
import { EventEmitter } from "node:events"
|
|
44
44
|
import { randomUUID } from "node:crypto"
|
|
45
45
|
|
|
46
|
+
import { AsyncTtlCache } from "./async-ttl-cache.mjs"
|
|
46
47
|
import { CodexAppServerClient } from "./codex-appserver-client.mjs"
|
|
47
48
|
import {
|
|
48
49
|
snapshotCodexDeliveryAudit,
|
|
@@ -323,6 +324,7 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
323
324
|
listThreadsFromDisk,
|
|
324
325
|
historyMaxItems,
|
|
325
326
|
historyMaxBytes,
|
|
327
|
+
listThreadsCacheTtlMs,
|
|
326
328
|
} = {}) {
|
|
327
329
|
super()
|
|
328
330
|
this.codexCmd = codexCmd
|
|
@@ -357,10 +359,12 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
357
359
|
/** @type {Map<string, object>} stream_id -> session record */
|
|
358
360
|
this.sessions = new Map()
|
|
359
361
|
this._accountRateLimitsByProfile = new Map()
|
|
360
|
-
//
|
|
361
|
-
//
|
|
362
|
-
//
|
|
363
|
-
|
|
362
|
+
// 複数 Cockpit タブ + pre-attach/ready の連続 request が、同じ cwd で rollout
|
|
363
|
+
// header 最大 800 件を短時間に何度も走査するのを防ぐ。thread picker の読み取り専用
|
|
364
|
+
// データなので数秒の鮮度差は許容し、実 turn/attach の正本には使わない。各 key の
|
|
365
|
+
// AsyncTtlCache が single-flight と、thread/start 中の世代切替をまとめて担う。
|
|
366
|
+
this._listThreadsCacheTtlMs = listThreadsCacheTtlMs ?? 5_000
|
|
367
|
+
this._listThreadsCache = new Map()
|
|
364
368
|
}
|
|
365
369
|
|
|
366
370
|
/**
|
|
@@ -576,6 +580,9 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
576
580
|
? await client.request("thread/resume", { ...startParams, threadId: resumeThreadId })
|
|
577
581
|
: await client.request("thread/start", startParams)
|
|
578
582
|
session.threadId = result?.thread?.id
|
|
583
|
+
// thread/start は一覧そのものを変更するため、pre-attach で得た短期キャッシュを破棄する。
|
|
584
|
+
// resume/reattach は既存 thread の再閲覧なのでキャッシュを維持し、ready 後の重複走査を防ぐ。
|
|
585
|
+
if (!resuming) this._invalidateListThreadsCacheForCwd(cwd)
|
|
579
586
|
this.logger?.info?.(
|
|
580
587
|
{
|
|
581
588
|
stream_id,
|
|
@@ -836,13 +843,33 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
836
843
|
// sessionsDir 未指定 (旧経路) のときだけ env 由来の値へフォールバックする。
|
|
837
844
|
const readRoot = sessionsDir || process.env.CODEX_HOME || ""
|
|
838
845
|
const key = `${readRoot}::${cwd || ""}::${limit ?? ""}`
|
|
839
|
-
|
|
840
|
-
if (
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
+
let entry = this._listThreadsCache.get(key)
|
|
847
|
+
if (!entry) {
|
|
848
|
+
entry = {
|
|
849
|
+
cwd: cwd || "",
|
|
850
|
+
cache: new AsyncTtlCache({ ttlMs: this._listThreadsCacheTtlMs }),
|
|
851
|
+
}
|
|
852
|
+
this._listThreadsCache.set(key, entry)
|
|
853
|
+
this._pruneListThreadsCache()
|
|
854
|
+
}
|
|
855
|
+
return entry.cache.get(() => this._doListThreads({ cwd, limit, sessionsDir }))
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
_invalidateListThreadsCacheForCwd(cwd) {
|
|
859
|
+
const normalizedCwd = cwd || ""
|
|
860
|
+
for (const entry of this._listThreadsCache.values()) {
|
|
861
|
+
if (entry.cwd === normalizedCwd) entry.cache.clear()
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
_pruneListThreadsCache() {
|
|
866
|
+
if (this._listThreadsCache.size <= 256) return
|
|
867
|
+
for (const [key, entry] of this._listThreadsCache) {
|
|
868
|
+
if (this._listThreadsCache.size <= 256) break
|
|
869
|
+
// 走査中の entry を map から外すと、同じ key の次 caller が別走査を開始して
|
|
870
|
+
// single-flight を破る。完了済みの古い entry だけを先に落とす。
|
|
871
|
+
if (!entry.cache.inFlight) this._listThreadsCache.delete(key)
|
|
872
|
+
}
|
|
846
873
|
}
|
|
847
874
|
|
|
848
875
|
async _doListThreads({ cwd, limit, sessionsDir } = {}) {
|
|
@@ -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,8 @@ 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"
|
|
51
|
+
import { AsyncTtlCache } from "./async-ttl-cache.mjs"
|
|
50
52
|
import { PtyBridge } from "./pty-bridge.mjs"
|
|
51
53
|
import { ClaudeStreamBridge } from "./claude-stream-bridge.mjs"
|
|
52
54
|
import { CodexMcpControl } from "./codex-mcp-control.mjs"
|
|
@@ -91,6 +93,7 @@ import {
|
|
|
91
93
|
} from "./claude-credentials.mjs"
|
|
92
94
|
import {
|
|
93
95
|
buildClaudeCmd,
|
|
96
|
+
buildWorktreeIndex,
|
|
94
97
|
createSession as createTmuxSession,
|
|
95
98
|
createWorktreeDir,
|
|
96
99
|
execTmux,
|
|
@@ -337,6 +340,8 @@ export function isReadOnlyRpc(type) {
|
|
|
337
340
|
type === "claude.history.request" ||
|
|
338
341
|
type === "claude.sessions.request" ||
|
|
339
342
|
type === "codex.sessions.request" ||
|
|
343
|
+
type === "profile.list" ||
|
|
344
|
+
type === "codex.profile.list" ||
|
|
340
345
|
// T06603 (2026-07-25): キュー snapshot の pull。in-memory 配列を読むだけで副作用が無く、
|
|
341
346
|
// かつ「直列キュー前方の重い処理で再同期が遅延する」と送信待ちチップの固着が長引く
|
|
342
347
|
// (再同期は固着を直すための経路なので、詰まりやすい経路に置いてはならない)。
|
|
@@ -1088,7 +1093,7 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
1088
1093
|
})
|
|
1089
1094
|
}
|
|
1090
1095
|
|
|
1091
|
-
// Hub からのメッセージ dispatch
|
|
1096
|
+
// Hub からのメッセージ dispatch は、接続世代 + 機能レーン単位で直列実行する。
|
|
1092
1097
|
//
|
|
1093
1098
|
// `EventEmitter.emit` は async リスナーの完了を待たないため、`async (msg) =>
|
|
1094
1099
|
// await dispatch(msg, ...)` の形だと複数 WS メッセージが連続して届いたとき
|
|
@@ -1103,50 +1108,29 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
1103
1108
|
// 結果として paste が tmux 内部状態 (copy mode 等) を抜ける前に届き、
|
|
1104
1109
|
// claude TUI に正しく入らない事象が散発する。
|
|
1105
1110
|
//
|
|
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
|
-
})
|
|
1111
|
+
// 旧来の global Promise chain は、ある cwd の tmux/codex 処理が別 cwd の
|
|
1112
|
+
// claude.attach と reconnect ready/sync まで 60〜203 秒止める HoL blocking を
|
|
1113
|
+
// 本番で起こした。DispatchScheduler は同一 cwd・同一 CLI の順序だけを維持し、
|
|
1114
|
+
// tmux/control・Claude・Codex・別 cwd を独立レーンへ分離する。
|
|
1115
|
+
const dispatchCtx = {
|
|
1116
|
+
...ctx,
|
|
1117
|
+
client,
|
|
1118
|
+
ptyBridge,
|
|
1119
|
+
claudeBridge,
|
|
1120
|
+
codexBridge,
|
|
1121
|
+
codexMcpControl,
|
|
1122
|
+
uploadManager,
|
|
1123
|
+
}
|
|
1124
|
+
const dispatchScheduler = new DispatchScheduler({
|
|
1125
|
+
logger,
|
|
1126
|
+
run: (msg) => dispatch(msg, dispatchCtx),
|
|
1127
|
+
// pty fast-path / read-only RPC は従来どおり即時実行。接続 lifecycle は
|
|
1128
|
+
// scheduler 自身が常に priority 扱いする。
|
|
1129
|
+
shouldBypass: (type) => isFastPathMessage(type) || isReadOnlyRpc(type),
|
|
1149
1130
|
})
|
|
1131
|
+
client.on("close", () => dispatchScheduler.advanceGeneration("ws_close"))
|
|
1132
|
+
client.on("open", () => dispatchScheduler.advanceGeneration("ws_open"))
|
|
1133
|
+
client.on("message", (msg) => dispatchScheduler.enqueue(msg))
|
|
1150
1134
|
|
|
1151
1135
|
client.connect()
|
|
1152
1136
|
|
|
@@ -1735,6 +1719,11 @@ const lastTurnTextsCache = new Map()
|
|
|
1735
1719
|
*/
|
|
1736
1720
|
const lastTurnTextsInFlight = new Map()
|
|
1737
1721
|
|
|
1722
|
+
// 複数 Cockpit タブは各自 8 秒 poll を持つため、位相がずれると agent は同じ
|
|
1723
|
+
// tmux/worktree/jsonl スナップショットを数秒おきに再構築してしまう。5 秒だけ成功値を
|
|
1724
|
+
// 共有し、同時要求は single-flight に畳む。create/kill/worktree 変更時は明示 clear する。
|
|
1725
|
+
const tmuxSessionsSnapshotCache = new AsyncTtlCache({ ttlMs: 5_000 })
|
|
1726
|
+
|
|
1738
1727
|
/** cold cache 時に全セッションを一斉に読まないための同時実行上限。 */
|
|
1739
1728
|
const LAST_TEXTS_CONCURRENCY = 4
|
|
1740
1729
|
|
|
@@ -1968,6 +1957,9 @@ async function startSessionEventWatcher({ client, logger, readinessTracker }) {
|
|
|
1968
1957
|
"session.event push",
|
|
1969
1958
|
)
|
|
1970
1959
|
}
|
|
1960
|
+
// 直前に作った一覧 snapshot がこの event より古い場合、次の poll で last_event を
|
|
1961
|
+
// 逆戻りさせない。build 中なら AsyncTtlCache が新世代へ合流し直す。
|
|
1962
|
+
tmuxSessionsSnapshotCache.clear()
|
|
1971
1963
|
client.send({
|
|
1972
1964
|
type: "session.event",
|
|
1973
1965
|
session_name: sessionName,
|
|
@@ -2292,6 +2284,7 @@ export async function pushImmediateChatState({
|
|
|
2292
2284
|
for (const s of states) {
|
|
2293
2285
|
if (s.cwd !== cwd) continue
|
|
2294
2286
|
if (cliKindForSessionName(s.session_name) !== cliKind) continue
|
|
2287
|
+
tmuxSessionsSnapshotCache.clear()
|
|
2295
2288
|
client.send({
|
|
2296
2289
|
type: "session.state",
|
|
2297
2290
|
session_name: s.session_name,
|
|
@@ -2398,6 +2391,7 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
|
|
|
2398
2391
|
// これで再接続直後の次 tick (≤intervalMs) に短縮する。初回接続時は空 Map の clear で no-op。
|
|
2399
2392
|
const onReopen = () => {
|
|
2400
2393
|
lastByName.clear()
|
|
2394
|
+
tmuxSessionsSnapshotCache.clear()
|
|
2401
2395
|
}
|
|
2402
2396
|
client.on("open", onReopen)
|
|
2403
2397
|
|
|
@@ -2696,6 +2690,9 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
|
|
|
2696
2690
|
"session.state push",
|
|
2697
2691
|
)
|
|
2698
2692
|
}
|
|
2693
|
+
// state push より古い一覧を後続 poll が返すと sidebar 行の status/context が
|
|
2694
|
+
// 逆戻りするため、送信前に snapshot 世代を切り替える。
|
|
2695
|
+
tmuxSessionsSnapshotCache.clear()
|
|
2699
2696
|
client.send({
|
|
2700
2697
|
type: "session.state",
|
|
2701
2698
|
session_name: s.session_name,
|
|
@@ -2733,6 +2730,7 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
|
|
|
2733
2730
|
// の消滅はここが唯一の検知点。frontend が states/lastEvent を忘れてドットを
|
|
2734
2731
|
// 'down' に倒せるよう、ローカル Map を forget する前に通知する。
|
|
2735
2732
|
try {
|
|
2733
|
+
tmuxSessionsSnapshotCache.clear()
|
|
2736
2734
|
client.send({ type: "session.gone", session_name: name })
|
|
2737
2735
|
} catch {
|
|
2738
2736
|
/* ignore */
|
|
@@ -4452,6 +4450,7 @@ async function dispatch(msg, ctx) {
|
|
|
4452
4450
|
ctx.claudeBridge?.shutdown()
|
|
4453
4451
|
// tmux TUI セッションも旧アカウントのまま残さない: kill → 同名・同 cwd で再作成。
|
|
4454
4452
|
const tmuxResult = await restartAllTmuxSessionsForProfileSwitch(ctx)
|
|
4453
|
+
tmuxSessionsSnapshotCache.clear()
|
|
4455
4454
|
// 切替先の config ディレクトリに認証情報が無ければ要ログイン (バッジ/案内用)。
|
|
4456
4455
|
const hasCreds = isKeychainPlatform()
|
|
4457
4456
|
? await profileHasKeychainCredentials(target.configDir)
|
|
@@ -4591,6 +4590,7 @@ async function dispatch(msg, ctx) {
|
|
|
4591
4590
|
// Codex セッションだけ撤去 (Claude セッションは残す = 独立切替)。
|
|
4592
4591
|
const before = ctx.codexBridge?.sessions?.size ?? 0
|
|
4593
4592
|
ctx.codexBridge?.shutdown()
|
|
4593
|
+
tmuxSessionsSnapshotCache.clear()
|
|
4594
4594
|
const hasCreds = await codexProfiles.hasCodexCredentials(target.configDir)
|
|
4595
4595
|
const needsLogin = !hasCreds
|
|
4596
4596
|
const isDefaultTarget = target.id === codexProfiles.DEFAULT_CODEX_PROFILE_ID
|
|
@@ -4724,48 +4724,42 @@ async function dispatch(msg, ctx) {
|
|
|
4724
4724
|
}
|
|
4725
4725
|
case "tmux.list_sessions": {
|
|
4726
4726
|
try {
|
|
4727
|
-
|
|
4728
|
-
|
|
4729
|
-
|
|
4730
|
-
|
|
4731
|
-
|
|
4732
|
-
|
|
4733
|
-
|
|
4734
|
-
|
|
4735
|
-
|
|
4727
|
+
const snapshot = await tmuxSessionsSnapshotCache.get(async () => {
|
|
4728
|
+
// worktree index は listSessions の親判定と stub 列挙で共用する。従来は同じ
|
|
4729
|
+
// 全 workspace + git branch 走査を 1 応答内で 2 回直列実行していた。
|
|
4730
|
+
const worktreeIndex = await buildWorktreeIndex()
|
|
4731
|
+
// boundSessions: 各行の context% (ドーナツ) を bound session の jsonl から引く
|
|
4732
|
+
// (cwd dir 内の別セッション jsonl を最新と取り違える=メタ欠陥#1 の根治)。
|
|
4733
|
+
const sessions = await listTmuxSessions({
|
|
4734
|
+
plugins: ctx.plugins,
|
|
4735
|
+
logger: ctx.logger,
|
|
4736
|
+
boundSessions: ctx.tuiReboundSessions,
|
|
4737
|
+
claudeBridge: ctx.claudeBridge,
|
|
4738
|
+
worktreeIndex,
|
|
4739
|
+
})
|
|
4740
|
+
const [lastEventByName, lastTextsByName] = await Promise.all([
|
|
4741
|
+
readAllSessionEvents(),
|
|
4742
|
+
readAllSessionLastTexts(sessions, ctx.getSessionStateSnapshot),
|
|
4743
|
+
])
|
|
4744
|
+
const enriched = enrichTmuxSessionsForListResponse(
|
|
4745
|
+
sessions,
|
|
4746
|
+
lastEventByName,
|
|
4747
|
+
ctx.getSessionStateSnapshot,
|
|
4748
|
+
lastTextsByName,
|
|
4749
|
+
)
|
|
4750
|
+
const liveNames = new Set(enriched.map((s) => s.name))
|
|
4751
|
+
let worktreeStubs = []
|
|
4752
|
+
try {
|
|
4753
|
+
worktreeStubs = await listWorktreeStubs(liveNames, { worktreeIndex })
|
|
4754
|
+
} catch (err) {
|
|
4755
|
+
ctx.logger?.warn?.({ err: err?.message }, "listWorktreeStubs failed")
|
|
4756
|
+
}
|
|
4757
|
+
return { sessions: enriched, worktree_stubs: worktreeStubs }
|
|
4736
4758
|
})
|
|
4737
|
-
// 各 session の最新 hook event を /tmp/cockpit_session_events/ から読んで
|
|
4738
|
-
// レスポンスに含める (Phase 4: ハードリロード時の lastEvent 復元用)。
|
|
4739
|
-
// fs.watch 由来の session.event push は揮発性のため、frontend が新規
|
|
4740
|
-
// マウントすると過去 event を取れず全グレー表示になる事象を解消する。
|
|
4741
|
-
const lastEventByName = await readAllSessionEvents()
|
|
4742
|
-
// サイドバー行に「何を依頼したか」を出すため、各セッションの jsonl 末尾から
|
|
4743
|
-
// 最新の依頼 / 応答テキストを読む (mtime キャッシュ付き)。失敗は空に落ちる。
|
|
4744
|
-
const lastTextsByName = await readAllSessionLastTexts(
|
|
4745
|
-
sessions,
|
|
4746
|
-
ctx.getSessionStateSnapshot,
|
|
4747
|
-
)
|
|
4748
|
-
const enriched = enrichTmuxSessionsForListResponse(
|
|
4749
|
-
sessions,
|
|
4750
|
-
lastEventByName,
|
|
4751
|
-
ctx.getSessionStateSnapshot,
|
|
4752
|
-
lastTextsByName,
|
|
4753
|
-
)
|
|
4754
|
-
// cockpit (PR 1719) で未起動 worktree をサイドバーに可視化するために
|
|
4755
|
-
// filesystem 上は存在するが tmux session が無い worktree dir のリストを
|
|
4756
|
-
// 同梱する。古い cockpit は worktree_stubs を無視するので互換 OK。
|
|
4757
|
-
const liveNames = new Set(enriched.map((s) => s.name))
|
|
4758
|
-
let worktreeStubs = []
|
|
4759
|
-
try {
|
|
4760
|
-
worktreeStubs = await listWorktreeStubs(liveNames)
|
|
4761
|
-
} catch (err) {
|
|
4762
|
-
ctx.logger?.warn?.({ err: err?.message }, "listWorktreeStubs failed")
|
|
4763
|
-
}
|
|
4764
4759
|
ctx.client.send({
|
|
4765
4760
|
type: "tmux.sessions",
|
|
4766
4761
|
request_id: msg.request_id,
|
|
4767
|
-
|
|
4768
|
-
worktree_stubs: worktreeStubs,
|
|
4762
|
+
...snapshot,
|
|
4769
4763
|
})
|
|
4770
4764
|
} catch (err) {
|
|
4771
4765
|
ctx.client.send({
|
|
@@ -4815,6 +4809,7 @@ async function dispatch(msg, ctx) {
|
|
|
4815
4809
|
typeof msg.initial_prompt === "string" ? msg.initial_prompt : undefined,
|
|
4816
4810
|
logger: ctx.logger,
|
|
4817
4811
|
})
|
|
4812
|
+
tmuxSessionsSnapshotCache.clear()
|
|
4818
4813
|
ctx.client.send({
|
|
4819
4814
|
type: "tmux.create_session.result",
|
|
4820
4815
|
request_id: msg.request_id,
|
|
@@ -4885,6 +4880,7 @@ async function dispatch(msg, ctx) {
|
|
|
4885
4880
|
typeof msg.initial_prompt === "string" ? msg.initial_prompt : undefined,
|
|
4886
4881
|
logger: ctx.logger,
|
|
4887
4882
|
})
|
|
4883
|
+
tmuxSessionsSnapshotCache.clear()
|
|
4888
4884
|
ctx.client.send({
|
|
4889
4885
|
type: "worktree.create.result",
|
|
4890
4886
|
request_id: msg.request_id,
|
|
@@ -4962,6 +4958,7 @@ async function dispatch(msg, ctx) {
|
|
|
4962
4958
|
const force = msg.force === true
|
|
4963
4959
|
try {
|
|
4964
4960
|
const result = await removeWorktreeDir(name, { force })
|
|
4961
|
+
if (result.dirty !== true) tmuxSessionsSnapshotCache.clear()
|
|
4965
4962
|
ctx.client.send({
|
|
4966
4963
|
type: "worktree.remove.result",
|
|
4967
4964
|
request_id: msg.request_id,
|
|
@@ -4984,6 +4981,7 @@ async function dispatch(msg, ctx) {
|
|
|
4984
4981
|
}
|
|
4985
4982
|
case "tmux.kill_session": {
|
|
4986
4983
|
await handleKillSession(msg, ctx)
|
|
4984
|
+
tmuxSessionsSnapshotCache.clear()
|
|
4987
4985
|
return
|
|
4988
4986
|
}
|
|
4989
4987
|
case "skills.request": {
|
package/src/tmux.mjs
CHANGED
|
@@ -174,7 +174,7 @@ export async function createWorktreeDir(parentDir, branch) {
|
|
|
174
174
|
*
|
|
175
175
|
* 移植元: D00000_cockpit/webapp/lib/workspaces.ts (findWorktrees)
|
|
176
176
|
*/
|
|
177
|
-
async function buildWorktreeIndex() {
|
|
177
|
+
export async function buildWorktreeIndex() {
|
|
178
178
|
const out = new Map()
|
|
179
179
|
const projectsBase = hubProjectsBase()
|
|
180
180
|
let projects
|
|
@@ -240,8 +240,11 @@ async function buildWorktreeParentMap() {
|
|
|
240
240
|
* worktree (`<wt>__codex`) が「未起動 stub」としても同時に列挙され、サイドバーに live 行と
|
|
241
241
|
* stub 行が二重表示される事故を防ぐ。
|
|
242
242
|
*/
|
|
243
|
-
export async function listWorktreeStubs(liveSessionNames) {
|
|
244
|
-
const idx =
|
|
243
|
+
export async function listWorktreeStubs(liveSessionNames, opts = {}) {
|
|
244
|
+
const idx =
|
|
245
|
+
opts.worktreeIndex instanceof Map
|
|
246
|
+
? opts.worktreeIndex
|
|
247
|
+
: await buildWorktreeIndex()
|
|
245
248
|
const liveBaseNames = new Set()
|
|
246
249
|
for (const n of liveSessionNames) liveBaseNames.add(baseSessionName(n))
|
|
247
250
|
const stubs = []
|
|
@@ -649,7 +652,16 @@ export async function listSessions(opts = {}) {
|
|
|
649
652
|
// pane 解析 (state.detectSessionState) はフォーマット変動に脆いので、
|
|
650
653
|
// statusLine cache がある cwd では優先採用する。
|
|
651
654
|
const [wtParentMap, sessionUsages] = await Promise.all([
|
|
652
|
-
|
|
655
|
+
opts.worktreeIndex instanceof Map
|
|
656
|
+
? Promise.resolve(
|
|
657
|
+
new Map(
|
|
658
|
+
[...opts.worktreeIndex].map(([name, info]) => [
|
|
659
|
+
name,
|
|
660
|
+
info.parent_session_name,
|
|
661
|
+
]),
|
|
662
|
+
),
|
|
663
|
+
)
|
|
664
|
+
: buildWorktreeParentMap(),
|
|
653
665
|
getSessionUsages().catch(() => []),
|
|
654
666
|
])
|
|
655
667
|
// cwd → context_pct の lookup (同一 cwd で複数 session_id ある場合は最新 mtime 優先)
|