@cocorograph/hub-agent 0.8.21 → 0.8.22

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.
@@ -0,0 +1,273 @@
1
+ /**
2
+ * Hub API 呼び出しの期限(1 往復ごと)と、セッション起動あたりの全体予算。
3
+ *
4
+ * ⚠️ ここが保証するのは次の 3 つ **だけ**。これを超える断定(「同期全体が N 秒以内」
5
+ * 「セッション起動が必ず N 秒で終わる」等)を書かないこと:
6
+ * 1. 各 HTTP 往復(headers 受信 〜 body 消費)は `HUB_REQUEST_TIMEOUT_MS` で打ち切る
7
+ * 2. `ensureClaudeMd` + `syncClaudeMdWithHub` は、**予算の生成時から数えた経過時間**が
8
+ * `SESSION_BUDGET_MS` を超えたら打ち切る(セッション起動あたり 1 本の予算)
9
+ * 3. 予算は **wall-clock の期限**であり、通信の待ち時間を積み上げた総量ではない。
10
+ * ファイル I/O・レンダリング・イベントループの停止も残量を減らす。ただし
11
+ * **打ち切る対象は Hub 往復と、その後に続く同期の処理**であって、それ以外の
12
+ * 処理を中断はしない
13
+ *
14
+ * ⚠️ 3 を「通信の待ち時間だけを積算する」実装に変えないこと。director と
15
+ * repositories は 1 本の deadline を共有して**並列**に待つので待ち時間が二重計上され、
16
+ * かつ CPU が詰まっている間・スリープ中は残量が減らないので **上限が事実上無制限**に
17
+ * なる。wall-clock 上限のままにしておくことが「セッション起動を有限にする」の根拠。
18
+ *
19
+ * `src/claude-md.mjs`(初期化)と `src/claude-md-sync.mjs`(双方向同期)の両方から
20
+ * 使うため独立モジュールにしてある。**2 系統に複製しないこと**(片方だけ直して
21
+ * ドリフトする)。
22
+ */
23
+
24
+ /**
25
+ * Hub API **1 往復あたり**(headers 受信 〜 body 消費)に掛ける既定の期限 (ms)。
26
+ *
27
+ * 15 秒とした根拠: 短くしたい誘惑があるが、低速回線・スリープ復帰直後の**正常な**
28
+ * 往復を恒常的に失敗させる方が害が大きい(同期が毎回諦めると正本がドリフトし続ける)。
29
+ * 健全な回線での往復は 1 秒程度で完了するので、15 秒は「遅いが正常」を通しつつ
30
+ * 「返ってこない」を切るための余裕として置いている。
31
+ * ⚠️ **本文サイズを根拠に書かないこと。** CLAUDE.md 正本の大きさは案件ごとに違い、
32
+ * 運用のなかで増減する。具体値を書くと必ず陳腐化する(旧コメントに残っていた
33
+ * 「往復は本文が小さいから速い」という断定は、実データの正本サイズと矛盾していた)。
34
+ * ⚠️ 導入前は **無制限**(body を閉じないレスポンスで往復が永久に返らない)だった。
35
+ */
36
+ export const HUB_REQUEST_TIMEOUT_MS = 15_000
37
+
38
+ /**
39
+ * セッション起動 1 回あたりの **wall-clock 予算** (ms)。予算の生成時から数えた
40
+ * 残り時間で、通信の待ち時間を積み上げた総量ではない。
41
+ *
42
+ * 1 往復に期限があっても、初期化と同期は **複数往復**を行うため、その本数だけ
43
+ * 足し算されて `createSession` が止まる時間の上限が決まらない。そこで「起動 1 回で
44
+ * 使ってよい wall-clock」をここで決め、超えたら **何も書かずに次回へ持ち越す**
45
+ * (不確定な状態で fallback 本文を書かない)。
46
+ * ⚠️ 打ち切る対象は **Hub 往復と、それに続く同期の処理**だけ。ファイル I/O・CPU 待ち・
47
+ * スリープも残量を減らすが、それらを直接中断はしない(「セッション起動全体が N 秒
48
+ * 以内」を保証するものではない。このモジュール冒頭の 3 が正本)。
49
+ * ⚠️ 往復の本数を具体的に列挙しないこと。経路が増減するたびにコメントだけが
50
+ * 古くなり、実在しない往復を数え続ける(過去に実際そうなった)。
51
+ */
52
+ export const SESSION_BUDGET_MS = 30_000
53
+
54
+ /**
55
+ * 1 往復ぶんの期限とキャンセル手段をまとめたもの。
56
+ *
57
+ * ⚠️ `Promise.race` だけでは**負けた側(`fetch` / `res.json()` / body 読み)が裏で
58
+ * 走り続ける**ため、期限超過時は必ず `AbortController.abort()`(+ 呼び出し側での
59
+ * `reader.cancel()`)を行い、正常終了時は `clear()` でタイマーを解除すること。
60
+ * タイマーは `unref()` して、これだけでプロセスが生き残らないようにする。
61
+ *
62
+ * @param {number} ms 期限。**0 以下なら生成時点で期限切れ**(= `guard` は即
63
+ * `{timedOut:true}`)。予算を使い切った後に「まだ少し待てる」と誤認しないため。
64
+ * @param {object} [opts]
65
+ * @param {AbortSignal} [opts.parentSignal] 親(=セッション全体予算)の signal。
66
+ * 親が abort したら子も abort し、`guard` を待っている呼び出しも解ける。
67
+ */
68
+ export function createDeadline(ms, { parentSignal = null } = {}) {
69
+ const controller = new AbortController()
70
+ let fire = null
71
+ const expiredPromise = new Promise((resolve) => {
72
+ fire = resolve
73
+ })
74
+ let fired = false
75
+ const expire = () => {
76
+ if (fired) return
77
+ fired = true
78
+ try {
79
+ controller.abort()
80
+ } catch {
81
+ /* noop */
82
+ }
83
+ fire()
84
+ }
85
+
86
+ // ⚠️ `AbortSignal.any` は Node 20.3+。engines は ">=20" なので **存在確認してから**
87
+ // 使い、無い環境では親 signal のリスナーで子 controller を abort する。
88
+ const hasAbortSignalAny = typeof AbortSignal !== "undefined" && typeof AbortSignal.any === "function"
89
+ let signal = controller.signal
90
+ let detachParent = () => {}
91
+ if (parentSignal) {
92
+ if (hasAbortSignalAny) {
93
+ // 合成 signal を fetch に渡す(親・子どちらの abort でも中断される)。
94
+ try {
95
+ signal = AbortSignal.any([controller.signal, parentSignal])
96
+ } catch {
97
+ signal = controller.signal
98
+ }
99
+ }
100
+ if (parentSignal.aborted) {
101
+ // 親が既に切れている=残り 0。生成時点で期限切れにする。
102
+ if (!hasAbortSignalAny) expire()
103
+ else {
104
+ fired = true
105
+ fire()
106
+ }
107
+ } else if (typeof parentSignal.addEventListener === "function") {
108
+ const onParentAbort = () => {
109
+ // any が使える環境では合成 signal 側が既に abort 済みなので、ここでは
110
+ // 「待っている guard を解く」ことだけを行えばよい。使えない環境では
111
+ // 子 controller を abort して signal を伝播させる。
112
+ if (hasAbortSignalAny) {
113
+ if (!fired) {
114
+ fired = true
115
+ fire()
116
+ }
117
+ } else {
118
+ expire()
119
+ }
120
+ }
121
+ parentSignal.addEventListener("abort", onParentAbort, { once: true })
122
+ detachParent = () => {
123
+ try {
124
+ parentSignal.removeEventListener("abort", onParentAbort)
125
+ } catch {
126
+ /* noop */
127
+ }
128
+ }
129
+ }
130
+ }
131
+
132
+ let timer = null
133
+ if (ms > 0) {
134
+ timer = setTimeout(expire, ms)
135
+ if (typeof timer.unref === "function") timer.unref()
136
+ } else {
137
+ // 0 以下 = 生成時点で期限切れ。タイマーを 1 tick 待たせない。
138
+ expire()
139
+ }
140
+
141
+ return {
142
+ get signal() {
143
+ return signal
144
+ },
145
+ /** 期限切れ済みか(`clear()` 後も状態は変わらない)。 */
146
+ expired() {
147
+ return fired
148
+ },
149
+ abort() {
150
+ try {
151
+ controller.abort()
152
+ } catch {
153
+ /* noop */
154
+ }
155
+ },
156
+ clear() {
157
+ if (timer) clearTimeout(timer)
158
+ detachParent()
159
+ },
160
+ /**
161
+ * `promise` を期限付きで待つ。
162
+ * @returns {Promise<{timedOut: boolean, value?: any, error?: any}>}
163
+ * 期限超過なら `{timedOut:true}`。負けた promise には必ずハンドラを付けて
164
+ * unhandled rejection にしない。
165
+ */
166
+ async guard(promise) {
167
+ return await Promise.race([
168
+ promise.then(
169
+ (value) => ({ timedOut: false, value }),
170
+ (error) => ({ timedOut: false, error }),
171
+ ),
172
+ expiredPromise.then(() => ({ timedOut: true })),
173
+ ])
174
+ },
175
+ }
176
+ }
177
+
178
+ /**
179
+ * セッション起動 1 回ぶんの **全体予算**(生成時からの wall-clock 期限)。
180
+ *
181
+ * ⚠️ 「通信の待ち時間の累積」ではない。`createSessionBudget(20)` を作って HTTP を
182
+ * 一度も呼ばずに 20ms 超待つと `expired()` は `true` になる。ファイル I/O・呼び出し元の
183
+ * 処理時間も残量を減らす(このモジュール冒頭の 3 を参照)。
184
+ *
185
+ * 使い方(呼び出し元は 1 本だけ作り、`finally` で必ず `clear()` する):
186
+ * const budget = createSessionBudget()
187
+ * try { await ensureClaudeMd({ ..., budget }); await syncClaudeMdWithHub({ ..., budget }) }
188
+ * finally { budget.clear() }
189
+ *
190
+ * 予算が尽きたら **書き込みを行わずに諦める**(`ensureClaudeMd` は `deferred`、
191
+ * `syncClaudeMdWithHub` は `skipped`)。どちらもセッション起動は止めない。
192
+ */
193
+ export function createSessionBudget(ms = SESSION_BUDGET_MS) {
194
+ const controller = new AbortController()
195
+ const budgetMs = Math.max(0, ms)
196
+ const deadlineAt = Date.now() + budgetMs
197
+ const children = new Set()
198
+
199
+ const abortAll = () => {
200
+ try {
201
+ controller.abort()
202
+ } catch {
203
+ /* noop */
204
+ }
205
+ }
206
+ let timer = null
207
+ if (budgetMs > 0) {
208
+ timer = setTimeout(abortAll, budgetMs)
209
+ if (typeof timer.unref === "function") timer.unref()
210
+ } else {
211
+ abortAll()
212
+ }
213
+
214
+ const remainingMs = () => Math.max(0, deadlineAt - Date.now())
215
+ const expired = () => controller.signal.aborted || remainingMs() <= 0
216
+
217
+ return {
218
+ signal: controller.signal,
219
+ remainingMs,
220
+ expired,
221
+ /**
222
+ * 1 往復ぶんの子 deadline を作る。期限は「1 往復の上限」と「予算の残り」の小さい方。
223
+ * 残りが 0 なら **生成時点で期限切れ** の子を返す(待たせない)。
224
+ */
225
+ childDeadline(perRequestMs = HUB_REQUEST_TIMEOUT_MS) {
226
+ const remaining = remainingMs()
227
+ const child = createDeadline(
228
+ expired() ? 0 : Math.min(perRequestMs, remaining),
229
+ { parentSignal: controller.signal },
230
+ )
231
+ children.add(child)
232
+ return child
233
+ },
234
+ /**
235
+ * 予算の後片付け。
236
+ *
237
+ * ⚠️ **子 deadline を abort してから** タイマーを外すこと。`clear()` は
238
+ * タイマーとリスナーを外すだけで signal を abort しないため、先に clear だけ
239
+ * すると「非 OK で早期 return したが body を閉じない応答」のように、まだ
240
+ * 生きているソケットを解放する手段が誰にも残らない(実測で、`ensureClaudeMd`
241
+ * が返った後も 3 本のソケットが接続中のままになった)。
242
+ *
243
+ * 呼び出し元(`src/tmux.mjs`)は初期化と同期の await が両方終わった後の
244
+ * `finally` で呼ぶので、ここで abort しても正常な往復は殺さない。
245
+ */
246
+ clear() {
247
+ if (timer) clearTimeout(timer)
248
+ for (const child of children) {
249
+ try {
250
+ child.abort()
251
+ } catch {
252
+ /* noop */
253
+ }
254
+ try {
255
+ child.clear()
256
+ } catch {
257
+ /* noop */
258
+ }
259
+ }
260
+ children.clear()
261
+ },
262
+ }
263
+ }
264
+
265
+ /**
266
+ * budget があれば子 deadline を、無ければ単独 deadline を作る。
267
+ *
268
+ * 「budget 省略時も従来どおり 1 往復の期限だけは掛かる」を 1 箇所に集約する
269
+ * (呼び出し側に三項演算子を散らすと、片方だけ budget を渡し忘れる)。
270
+ */
271
+ export function acquireDeadline(budget, perRequestMs = HUB_REQUEST_TIMEOUT_MS) {
272
+ return budget ? budget.childDeadline(perRequestMs) : createDeadline(perRequestMs)
273
+ }
package/src/tmux.mjs CHANGED
@@ -22,6 +22,7 @@ import { promisify } from "node:util"
22
22
 
23
23
  import { ensureClaudeMd } from "./claude-md.mjs"
24
24
  import { syncClaudeMdWithHub } from "./claude-md-sync.mjs"
25
+ import { createSessionBudget } from "./hub-request-deadline.mjs"
25
26
  import { detectSessionState, getSessionCwd } from "./state.mjs"
26
27
  import { shouldLaunchPaneCli } from "./pane-cli-flag.mjs"
27
28
  import { baseSessionName, sessionNameFor } from "./session-names.mjs"
@@ -836,45 +837,65 @@ export async function createSession(name, cwd, opts = {}) {
836
837
  // .bak.conflict に退避)。Hub が一度も保存されていないプロジェクトでは
837
838
  // 何もしない(テナント標準 CLAUDE.md の運用を尊重する)。
838
839
  // どちらも失敗しても session 起動自体は止めない (warn のみ)。
840
+ // ⚠️ 2 つの呼び出しで **1 本の budget を共有**する。1 往復ごとの期限だけでは
841
+ // 往復の本数ぶん足し算されてセッション起動が長時間止まりうるため、Hub 処理に
842
+ // 割り当てた wall-clock 予算(予算の生成時からの経過時間で、通信の待ち時間の
843
+ // 累積ではない)で全体を打ち切る。予算切れなら「何も書かずに次回へ持ち越す」。
839
844
  if (isUnderHubProjectsBase(resolvedCwd)) {
840
845
  const instructionDir = projectInstructionDir(resolvedCwd)
841
846
  const dirName = path.basename(instructionDir)
842
847
  const configFilename = opts.cliKind === "codex" ? "AGENTS.md" : "CLAUDE.md"
848
+ const budget = createSessionBudget()
849
+ // ⚠️ `ensureClaudeMd` の戻り値を捨てない。`source === 'existing'` は
850
+ // 「このセッションより前からローカルにファイルがあった」= **初回登録**
851
+ // (Hub に無くローカルにあるものを 1 度だけ Hub へ取り込む)の唯一の根拠で、
852
+ // ディスクに残さずここから同期へ手渡す。例外時は **undefined のまま渡さない**
853
+ // (=登録しない。何が起きたか分からない回に Hub を触らせない安全側)。
854
+ let localFilePreexisted
843
855
  try {
844
- await ensureClaudeMd({
845
- targetDir: instructionDir,
846
- dirName,
847
- hubUrl: opts.hubUrl,
848
- accessToken: opts.hubAccessToken,
849
- logger: opts.logger,
850
- fetchImpl: opts.fetchImpl,
851
- configFilename,
852
- })
853
- } catch (err) {
854
- opts.logger?.warn?.(
855
- { session: name, cwd: resolvedCwd, err: err?.message || String(err) },
856
- "ensureClaudeMd failed (workspace dir is usable, but no CLAUDE.md)",
857
- )
858
- }
859
- try {
860
- const syncResult = await syncClaudeMdWithHub({
861
- targetDir: instructionDir,
862
- dirName,
863
- hubUrl: opts.hubUrl,
864
- accessToken: opts.hubAccessToken,
865
- logger: opts.logger,
866
- fetchImpl: opts.fetchImpl,
867
- configFilename,
868
- })
869
- opts.logger?.debug?.(
870
- { session: name, dirName, sync: syncResult },
871
- "syncClaudeMdWithHub completed",
872
- )
873
- } catch (err) {
874
- opts.logger?.warn?.(
875
- { session: name, cwd: resolvedCwd, err: err?.message || String(err) },
876
- "syncClaudeMdWithHub failed (session starts anyway)",
877
- )
856
+ try {
857
+ const ensure = await ensureClaudeMd({
858
+ targetDir: instructionDir,
859
+ dirName,
860
+ hubUrl: opts.hubUrl,
861
+ accessToken: opts.hubAccessToken,
862
+ logger: opts.logger,
863
+ fetchImpl: opts.fetchImpl,
864
+ configFilename,
865
+ budget,
866
+ })
867
+ localFilePreexisted = ensure?.source === "existing"
868
+ } catch (err) {
869
+ opts.logger?.warn?.(
870
+ { session: name, cwd: resolvedCwd, err: err?.message || String(err) },
871
+ "ensureClaudeMd failed (workspace dir is usable, but no CLAUDE.md)",
872
+ )
873
+ }
874
+ try {
875
+ const syncResult = await syncClaudeMdWithHub({
876
+ targetDir: instructionDir,
877
+ dirName,
878
+ hubUrl: opts.hubUrl,
879
+ accessToken: opts.hubAccessToken,
880
+ logger: opts.logger,
881
+ fetchImpl: opts.fetchImpl,
882
+ configFilename,
883
+ budget,
884
+ // undefined のときはキーごと渡さない(呼び出し側の意図を明示する)。
885
+ ...(localFilePreexisted === undefined ? {} : { localFilePreexisted }),
886
+ })
887
+ opts.logger?.debug?.(
888
+ { session: name, dirName, sync: syncResult },
889
+ "syncClaudeMdWithHub completed",
890
+ )
891
+ } catch (err) {
892
+ opts.logger?.warn?.(
893
+ { session: name, cwd: resolvedCwd, err: err?.message || String(err) },
894
+ "syncClaudeMdWithHub failed (session starts anyway)",
895
+ )
896
+ }
897
+ } finally {
898
+ budget.clear()
878
899
  }
879
900
  }
880
901
  // 既存チェック