@cocorograph/hub-agent 0.8.18 → 0.8.20

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.
@@ -18,7 +18,10 @@
18
18
  *
19
19
  * 関連: backend Stage 1 / 1.5
20
20
  * GET /api/admin/project/claude-md-by-dir/?dir_name=...
21
- * PUT /api/admin/project/claude-md-by-dir/?dir_name=... body: { claude_md }
21
+ * PUT /api/admin/project/claude-md-by-dir/?dir_name=... body: { claude_md, expected_hash? }
22
+ * expected_hash は楽観ロック。GET で読んだ hash を必ず添えて送る(不一致なら 409)。
23
+ * CLI 種別を取り違えた本文(CLAUDE.md 正本へ AGENTS.md の内容 等)は 400
24
+ * `config_md_kind_mismatch` で拒否される。再送しても通らないので理由を分ける。
22
25
  */
23
26
  import fs from "node:fs/promises"
24
27
  import os from "node:os"
@@ -70,24 +73,151 @@ function makeBakName(prefix) {
70
73
  return `${prefix}.${ts}.bak`
71
74
  }
72
75
 
76
+ /**
77
+ * `makeBakName` が生成するタイムスタンプ部分の形。
78
+ *
79
+ * `new Date().toISOString()` は `YYYY-MM-DDTHH:mm:ss.sssZ` 固定で、`makeBakName` が
80
+ * `:` と `.` を `-` に置換するため `2026-08-01T12-34-56-789Z` になる。
81
+ * ⚠️ makeBakName を変えたらこの定数も必ず揃えること(`_internals` 経由の
82
+ * 往復テストが両者の食い違いを検知する)。
83
+ */
84
+ const BAK_TS_PATTERN = "\\d{4}-\\d{2}-\\d{2}T\\d{2}-\\d{2}-\\d{2}-\\d{3}Z"
85
+
86
+ function escapeRegExp(s) {
87
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
88
+ }
89
+
90
+ /**
91
+ * 「hub-agent が `makeBakName(prefix)` で作った .bak」だけに一致する正規表現を返す。
92
+ *
93
+ * FIFO 削除の対象集合をこれで絞る。単純な `startsWith(prefix + ".")` だと
94
+ * kind='normal'(prefix = `CLAUDE.md`)の集合に `CLAUDE.md.conflict.<ts>.bak` や
95
+ * 手作業で置かれた異物(`CLAUDE.md.contaminated.<数値>.bak` 等)まで入り込み、
96
+ * 名前降順では conflict が先頭に並ぶため **健全な通常 bak が保持枠から押し出されて
97
+ * 削除される**(実際に「手元から復元できない」事故の直接原因になった)。
98
+ *
99
+ * `.includes('.conflict.')` を弾くだけの対処にしないのは、将来別の名前が増えたときに
100
+ * 再び巻き添えになるため。「自分が作った形と一致するものだけ」を対象にする。
101
+ */
102
+ function bakNamePattern(prefix) {
103
+ return new RegExp(`^${escapeRegExp(prefix)}\\.${BAK_TS_PATTERN}\\.bak$`)
104
+ }
105
+
106
+ /**
107
+ * Hub API **1 往復あたり**(headers 受信 〜 body 消費)に掛ける既定の期限 (ms)。
108
+ *
109
+ * ⚠️ **このモジュールが保証するのは「各 HTTP 往復が 15 秒で打ち切られる」ことだけ**。
110
+ * 1 回の同期は GET と PUT を行うし、ファイル I/O と呼び出し元の処理も乗る。さらに
111
+ * 初期化経路(`src/claude-md.mjs` の `ensureClaudeMd`)にはまだ期限が無い。
112
+ * つまり **同期全体・セッション起動全体の budget はここでは決まらない**(別タスク)。
113
+ * ここに「全体で N 秒以内」と書くと、その瞬間から嘘になる。
114
+ *
115
+ * 15 秒とした根拠: 短くしたい誘惑があるが、低速回線・スリープ復帰直後の**正常な**
116
+ * 往復を恒常的に失敗させる方が害が大きい(同期が毎回諦めると正本がドリフトし続ける)。
117
+ * 実際の往復は本文 1 KB 未満・1 秒以内で完了するため 15 秒は十分な余裕がある。
118
+ * ⚠️ 変更前は **無制限**(body を閉じないレスポンスで往復が永久に返らない)だったので、
119
+ * 15 秒でも厳密な改善である。
120
+ */
121
+ const HUB_REQUEST_TIMEOUT_MS = 15_000
122
+
123
+ /**
124
+ * 1 往復ぶんの期限とキャンセル手段をまとめたもの。
125
+ *
126
+ * ⚠️ `Promise.race` だけでは**負けた側(`fetch` / `res.json()` / body 読み)が裏で
127
+ * 走り続ける**ため、期限超過時は必ず `AbortController.abort()`(+ 呼び出し側での
128
+ * `reader.cancel()`)を行い、正常終了時は `clear()` でタイマーを解除すること。
129
+ * タイマーは `unref()` して、これだけでプロセスが生き残らないようにする。
130
+ */
131
+ function createDeadline(ms) {
132
+ const controller = new AbortController()
133
+ let fire = null
134
+ const expired = new Promise((resolve) => {
135
+ fire = resolve
136
+ })
137
+ const timer = setTimeout(() => {
138
+ try {
139
+ controller.abort()
140
+ } catch {
141
+ /* noop */
142
+ }
143
+ fire()
144
+ }, ms)
145
+ if (typeof timer.unref === "function") timer.unref()
146
+ return {
147
+ signal: controller.signal,
148
+ abort() {
149
+ try {
150
+ controller.abort()
151
+ } catch {
152
+ /* noop */
153
+ }
154
+ },
155
+ clear() {
156
+ clearTimeout(timer)
157
+ },
158
+ /**
159
+ * `promise` を期限付きで待つ。
160
+ * @returns {Promise<{timedOut: boolean, value?: any, error?: any}>}
161
+ * 期限超過なら `{timedOut:true}`。負けた promise には必ずハンドラを付けて
162
+ * unhandled rejection にしない。
163
+ */
164
+ async guard(promise) {
165
+ return await Promise.race([
166
+ promise.then(
167
+ (value) => ({ timedOut: false, value }),
168
+ (error) => ({ timedOut: false, error }),
169
+ ),
170
+ expired.then(() => ({ timedOut: true })),
171
+ ])
172
+ },
173
+ }
174
+ }
175
+
73
176
  /**
74
177
  * Hub から claude-md-by-dir で正本を取得する。
75
178
  *
76
179
  * - 404 = 該当 Project 無し(Domain 型・local_dir 型 workspace)→ null
77
180
  * - その他のエラー → null(warn ログ)
78
181
  *
182
+ * ⚠️ **GET は PUT より先に走る**ので、ここに期限が無いと PUT 側だけ直しても
183
+ * 「セッション作成が止まらない」という目的を達成できない(同じ失敗の型)。
184
+ * 非 OK 時の本文は読まない(status だけで十分)。掛けているのは期限だけ。
185
+ *
79
186
  * @returns {Promise<null | {project_id, claude_md, claude_md_hash, claude_md_updated_at}>}
80
187
  */
81
- async function fetchHubClaudeMd({ hubUrl, accessToken, dirName, fetchImpl, logger }) {
188
+ async function fetchHubClaudeMd({
189
+ hubUrl,
190
+ accessToken,
191
+ dirName,
192
+ fetchImpl,
193
+ logger,
194
+ timeoutMs = HUB_REQUEST_TIMEOUT_MS,
195
+ }) {
82
196
  const f = fetchImpl || globalThis.fetch
83
197
  if (!f) return null
84
198
  const url =
85
199
  `${hubUrl.replace(/\/+$/, "")}/api/admin/project/claude-md-by-dir/` +
86
200
  `?dir_name=${encodeURIComponent(dirName)}`
201
+ const deadline = createDeadline(timeoutMs)
87
202
  try {
88
- const res = await f(url, {
89
- headers: { Authorization: `Bearer ${accessToken}` },
90
- })
203
+ const fetched = await deadline.guard(
204
+ // 同期的に throw する fetch 実装でも guard に載るよう then で包む。
205
+ Promise.resolve().then(() =>
206
+ f(url, {
207
+ headers: { Authorization: `Bearer ${accessToken}` },
208
+ signal: deadline.signal,
209
+ }),
210
+ ),
211
+ )
212
+ if (fetched.timedOut) {
213
+ logger?.warn?.(
214
+ { dirName, timeout_ms: timeoutMs },
215
+ "fetchHubClaudeMd: round-trip timed out; giving up (best-effort sync)",
216
+ )
217
+ return null
218
+ }
219
+ if (fetched.error) throw fetched.error
220
+ const res = fetched.value
91
221
  if (res.status === 404) return null
92
222
  if (!res.ok) {
93
223
  logger?.warn?.(
@@ -96,53 +226,325 @@ async function fetchHubClaudeMd({ hubUrl, accessToken, dirName, fetchImpl, logge
96
226
  )
97
227
  return null
98
228
  }
99
- return await res.json()
229
+ // body 消費にも同じ期限を掛ける(headers だけ返して body を閉じない応答対策)。
230
+ const parsed = await deadline.guard(Promise.resolve().then(() => res.json()))
231
+ if (parsed.timedOut) {
232
+ deadline.abort()
233
+ logger?.warn?.(
234
+ { dirName, timeout_ms: timeoutMs },
235
+ "fetchHubClaudeMd: response body read timed out",
236
+ )
237
+ return null
238
+ }
239
+ if (parsed.error) throw parsed.error
240
+ return parsed.value
100
241
  } catch (err) {
101
242
  logger?.warn?.(
102
243
  { dirName, err: err?.message || String(err) },
103
244
  "fetchHubClaudeMd: request failed",
104
245
  )
105
246
  return null
247
+ } finally {
248
+ deadline.clear()
249
+ }
250
+ }
251
+
252
+ /**
253
+ * 非 2xx レスポンスから拒否理由を取り出すときの allowlist と長さ上限。
254
+ *
255
+ * backend は「AGENTS.md の内容を CLAUDE.md 正本へ保存しようとした」PUT を 400 +
256
+ * `{"error":"config_md_kind_mismatch", "detail", "expected_kind", "detected_heading"}`
257
+ * で拒否する(2026-08-01 本番投入)。本文を読まないとこの拒否理由が誰にも見えず、
258
+ * 「同期が静かに止まる」状態になるため、非 OK のときだけ本文を読む。
259
+ *
260
+ * ただしレスポンス本文は **JSON とは限らない**(nginx / Cloudflare の HTML エラー
261
+ * ページが返ることがある)し、巨大だったり機密値を含みうる。そのため:
262
+ * - 載せるのは **allowlist したキーだけ**(本文を丸ごとログに出さない)
263
+ * - 各値に長さ上限を掛け、切り捨てたことが分かる形にする
264
+ * - JSON として読めない / allowlist キーが 1 つも無い場合は **本文を出さず**、
265
+ * status・content-type・本文長だけにする
266
+ */
267
+ const PUT_ERROR_FIELD_ALLOWLIST = ["error", "detail", "expected_kind", "detected_heading"]
268
+ const PUT_ERROR_FIELD_MAX_CHARS = 200
269
+
270
+ /**
271
+ * 非 2xx 本文の **受信上限(UTF-8 バイト)**。
272
+ *
273
+ * ⚠️ これは「parse の上限」ではなく「メモリに載せる上限」である。`res.text()` は
274
+ * 全文をメモリへ読み込んでからでないと長さが分からないため、100 MB の chunked body
275
+ * では 100 MB を抱えた後にしか気づけない。そこで `res.body` を**逐次読み**し、
276
+ * バイト数がこの上限を超えた時点で読み取りを打ち切って reader を cancel する。
277
+ *
278
+ * 64 KiB とした根拠: 実際の拒否応答(`config_md_kind_mismatch` の JSON)は 1 KB 未満、
279
+ * nginx / Cloudflare の HTML エラーページでも数 KB で、64 KiB あれば正当な診断情報は
280
+ * 必ず収まる。
281
+ */
282
+ const PUT_ERROR_BODY_MAX_BYTES = 64 * 1024
283
+
284
+ /** ログ・戻り値に載せる文字列に長さ上限を掛ける(切り捨てたことが分かる形にする)。 */
285
+ function truncateForLog(value, max = PUT_ERROR_FIELD_MAX_CHARS) {
286
+ const s = String(value)
287
+ if (s.length <= max) return s
288
+ return `${s.slice(0, max)}…[truncated ${s.length} chars]`
289
+ }
290
+
291
+ /**
292
+ * パース済みレスポンスから allowlist キーだけを取り出す。
293
+ *
294
+ * 値は文字列・数値・真偽値のみ採用する。オブジェクト / 配列は入れ子に何が入るか
295
+ * 保証できないので載せない。1 つも取れなければ null(=呼び出し側は本文を出さない)。
296
+ */
297
+ function pickAllowedErrorFields(parsed) {
298
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null
299
+ const out = {}
300
+ for (const key of PUT_ERROR_FIELD_ALLOWLIST) {
301
+ const v = parsed[key]
302
+ if (typeof v === "string") out[key] = truncateForLog(v)
303
+ else if (typeof v === "number" || typeof v === "boolean") out[key] = String(v)
106
304
  }
305
+ return Object.keys(out).length > 0 ? out : null
306
+ }
307
+
308
+ /**
309
+ * `res.body`(ReadableStream)をバイト上限付きで読む。
310
+ *
311
+ * - 上限を超えた時点で読み取りを打ち切る(`tooLarge = true`)。
312
+ * - ⚠️ 打ち切った断片は **JSON として解釈しない**(呼び出し側で parse を抑止する)。
313
+ * 壊れた JSON を読ませると、無い値を「取れた」と誤認しかねない。
314
+ * - ⚠️ `TextDecoder` は `{stream: true}` で回す。chunk 境界がマルチバイト文字の
315
+ * 途中に来ても壊れないようにするため。
316
+ */
317
+ async function readStreamWithByteCap(reader, maxBytes) {
318
+ const decoder = new TextDecoder("utf-8")
319
+ let text = ""
320
+ let bytes = 0
321
+ let tooLarge = false
322
+ try {
323
+ for (;;) {
324
+ const { done, value } = await reader.read()
325
+ if (done) break
326
+ const size = value?.byteLength ?? value?.length ?? 0
327
+ if (bytes + size > maxBytes) {
328
+ tooLarge = true
329
+ break
330
+ }
331
+ bytes += size
332
+ text += decoder.decode(value, { stream: true })
333
+ }
334
+ if (!tooLarge) text += decoder.decode() // 末尾の保留バイトを flush
335
+ } finally {
336
+ // 途中で抜けた場合に接続を解放する(上限超過・例外のどちらでも)。
337
+ try {
338
+ reader.cancel()?.catch?.(() => {})
339
+ } catch {
340
+ /* noop */
341
+ }
342
+ }
343
+ return { text, bytes, tooLarge }
344
+ }
345
+
346
+ /**
347
+ * 非 OK レスポンスの診断情報を安全に読み出す。
348
+ *
349
+ * 期限(`deadline`)とバイト上限の両方を掛ける。`res.body` を持たない実装
350
+ * (簡易スタブ / 別実装)では **例外を投げず** status だけで諦める(同期を壊さない)。
351
+ *
352
+ * @returns {Promise<{fields: object|null, contentType: string|null,
353
+ * declaredLength: number|null, bodyBytes: number|null,
354
+ * bodyTooLarge: boolean, timedOut: boolean}>}
355
+ */
356
+ async function readNonOkDiagnostics(res, deadline) {
357
+ const header = (name) =>
358
+ typeof res?.headers?.get === "function" ? res.headers.get(name) : null
359
+ const rawType = header("content-type")
360
+ const contentType = rawType ? truncateForLog(rawType, 120) : null
361
+ // ⚠️ Number(null) は 0 になる。ヘッダー欠落を「0 バイトと申告された」と読むと
362
+ // ログが嘘をつくので、欠落・空文字は「申告なし (null)」として扱う。
363
+ const declaredRaw = header("content-length")
364
+ const declaredNum =
365
+ declaredRaw === null || declaredRaw === undefined || declaredRaw === ""
366
+ ? Number.NaN
367
+ : Number(declaredRaw)
368
+ const declaredLength =
369
+ Number.isFinite(declaredNum) && declaredNum >= 0 ? declaredNum : null
370
+ const base = {
371
+ fields: null,
372
+ contentType,
373
+ declaredLength,
374
+ bodyBytes: null,
375
+ bodyTooLarge: false,
376
+ timedOut: false,
377
+ }
378
+
379
+ // Content-Length による早期拒否。⚠️ 欠落・虚偽がありうるので、これに頼らず
380
+ // stream 側のバイト上限も必ず掛ける(下の readStreamWithByteCap)。
381
+ if (declaredLength !== null && declaredLength > PUT_ERROR_BODY_MAX_BYTES) {
382
+ deadline?.abort()
383
+ return { ...base, bodyTooLarge: true }
384
+ }
385
+
386
+ const body = res?.body
387
+ if (!body || typeof body.getReader !== "function") return base
388
+ let reader = null
389
+ try {
390
+ reader = body.getReader()
391
+ } catch {
392
+ return base
393
+ }
394
+
395
+ const read = readStreamWithByteCap(reader, PUT_ERROR_BODY_MAX_BYTES)
396
+ // deadline 無しでも guard と同じ形({timedOut, value?, error?})に揃える。
397
+ // 形が揃っていないと、読み取りが reject したときに value を分解して落ちる。
398
+ const guarded = deadline
399
+ ? await deadline.guard(read)
400
+ : await read.then(
401
+ (value) => ({ timedOut: false, value }),
402
+ (error) => ({ timedOut: false, error }),
403
+ )
404
+ if (guarded.timedOut) {
405
+ // ⚠️ race で勝っただけでは読み取りが止まらない。cancel + abort まで行う。
406
+ try {
407
+ reader.cancel()?.catch?.(() => {})
408
+ } catch {
409
+ /* noop */
410
+ }
411
+ deadline?.abort()
412
+ return { ...base, timedOut: true }
413
+ }
414
+ if (guarded.error || !guarded.value) return base
415
+
416
+ const { text, bytes, tooLarge } = guarded.value
417
+ let fields = null
418
+ if (!tooLarge && typeof text === "string" && text.length > 0) {
419
+ try {
420
+ fields = pickAllowedErrorFields(JSON.parse(text))
421
+ } catch {
422
+ fields = null // HTML エラーページ等。本文は出さない。
423
+ }
424
+ }
425
+ return { ...base, fields, bodyBytes: bytes, bodyTooLarge: tooLarge }
107
426
  }
108
427
 
109
428
  /**
110
429
  * Hub に PUT で本文を上書きする。Hub 側は履歴を積んでから保存する。
111
430
  *
112
- * @returns {Promise<boolean>} 成功なら true
431
+ * `expectedHash` を渡すと backend が行ロック下で現在 hash と照合し、不一致なら
432
+ * **409** を返す(楽観ロック)。「GET してから PUT するまでに他端末 / Hub UI が
433
+ * 動いた」ケースを検出するために必ず渡すこと。渡さないと素通りで盲目的に上書きする。
434
+ *
435
+ * 戻り値は「通信できたか」「HTTP ステータス」「レスポンス本文」「拒否理由」を分けて返す。
436
+ * 呼び出し側は 409(=読んだ後に Hub が動いた。次回 sync で再判定すべき)・
437
+ * 400 config_md_kind_mismatch(=同じ本文を再送しても永久に通らない)・
438
+ * 通信失敗(=そもそも届いていない)を区別する必要があり、真偽値では表現できない。
439
+ * また `claude_md_updated_at` をメタへ記録するためにレスポンス本文も要る。
440
+ *
441
+ * @returns {Promise<{ok: boolean, status: number|null, data: object|null,
442
+ * error: object|null}>}
443
+ * status は通信自体が失敗した場合のみ null。
444
+ * error は非 OK 時の allowlist 済み拒否理由(取れなければ null)。
113
445
  */
114
- async function putHubClaudeMd({ hubUrl, accessToken, dirName, body, fetchImpl, logger }) {
446
+ async function putHubClaudeMd({
447
+ hubUrl,
448
+ accessToken,
449
+ dirName,
450
+ body,
451
+ expectedHash,
452
+ fetchImpl,
453
+ logger,
454
+ timeoutMs = HUB_REQUEST_TIMEOUT_MS,
455
+ }) {
115
456
  const f = fetchImpl || globalThis.fetch
116
- if (!f) return false
457
+ if (!f) return { ok: false, status: null, data: null, error: null }
117
458
  const url =
118
459
  `${hubUrl.replace(/\/+$/, "")}/api/admin/project/claude-md-by-dir/` +
119
460
  `?dir_name=${encodeURIComponent(dirName)}`
461
+ // expected_hash は「値があるときだけ」載せる。undefined を JSON 化すると
462
+ // キーごと消えるとはいえ、空文字を送ると backend 側で「空 hash と一致するか」の
463
+ // 照合に落ちるため、truthy のときのみ明示的に付与する。
464
+ const payload = expectedHash
465
+ ? { claude_md: body, expected_hash: expectedHash }
466
+ : { claude_md: body }
467
+ // headers 受信から body 消費までを 1 本の期限に収める(別々に張ると、
468
+ // headers だけ返して body を閉じない応答で結局止まる)。
469
+ const deadline = createDeadline(timeoutMs)
120
470
  try {
121
- const res = await f(url, {
122
- method: "PUT",
123
- headers: {
124
- Authorization: `Bearer ${accessToken}`,
125
- "Content-Type": "application/json",
126
- },
127
- body: JSON.stringify({ claude_md: body }),
128
- })
471
+ const fetched = await deadline.guard(
472
+ Promise.resolve().then(() =>
473
+ f(url, {
474
+ method: "PUT",
475
+ headers: {
476
+ Authorization: `Bearer ${accessToken}`,
477
+ "Content-Type": "application/json",
478
+ },
479
+ body: JSON.stringify(payload),
480
+ signal: deadline.signal,
481
+ }),
482
+ ),
483
+ )
484
+ if (fetched.timedOut) {
485
+ logger?.warn?.(
486
+ { dirName, timeout_ms: timeoutMs },
487
+ "putHubClaudeMd: round-trip timed out; giving up (best-effort sync)",
488
+ )
489
+ return { ok: false, status: null, data: null, error: null }
490
+ }
491
+ if (fetched.error) throw fetched.error
492
+ const res = fetched.value
129
493
  if (!res.ok) {
494
+ const diag = await readNonOkDiagnostics(res, deadline)
130
495
  logger?.warn?.(
131
- { dirName, status: res.status },
496
+ {
497
+ dirName,
498
+ status: res.status,
499
+ content_type: diag.contentType,
500
+ body_bytes: diag.bodyBytes,
501
+ ...(diag.declaredLength !== null ? { declared_length: diag.declaredLength } : {}),
502
+ // 完全長は分からないが「上限より大きい」ことは分かる、という記録。
503
+ ...(diag.bodyTooLarge ? { body_too_large: true } : {}),
504
+ ...(diag.timedOut ? { body_read_timed_out: true } : {}),
505
+ // allowlist で絞った拒否理由のみ。取れなければ本文は一切出さない。
506
+ ...(diag.fields || {}),
507
+ },
132
508
  "putHubClaudeMd: non-OK response",
133
509
  )
134
- return false
510
+ return { ok: false, status: res.status ?? null, data: null, error: diag.fields }
135
511
  }
136
- return true
512
+ // 成功時の本文読み取りにも同じ期限を掛ける。読めなければ data=null のまま返し、
513
+ // 呼び出し側が「updated_at が取れない」として skip する(メタは進めない)。
514
+ const parsed = await deadline.guard(Promise.resolve().then(() => res.json()))
515
+ let data = null
516
+ if (parsed.timedOut) {
517
+ deadline.abort()
518
+ logger?.warn?.(
519
+ { dirName, timeout_ms: timeoutMs },
520
+ "putHubClaudeMd: response body read timed out",
521
+ )
522
+ } else if (!parsed.error) {
523
+ data = parsed.value // 本文が読めなくても PUT 自体は成功している
524
+ }
525
+ return { ok: true, status: res.status ?? 200, data, error: null }
137
526
  } catch (err) {
138
527
  logger?.warn?.(
139
528
  { dirName, err: err?.message || String(err) },
140
529
  "putHubClaudeMd: request failed",
141
530
  )
142
- return false
531
+ return { ok: false, status: null, data: null, error: null }
532
+ } finally {
533
+ deadline.clear()
143
534
  }
144
535
  }
145
536
 
537
+ /**
538
+ * PUT レスポンスから「サーバーが実際に記録した更新時刻」を取り出す。
539
+ *
540
+ * 取れなければ null。呼び出し側は null のときメタを書かずに skip する
541
+ * (端末時刻などの推測値を書くと、ずれがメタに固定されて後から追えなくなる)。
542
+ */
543
+ function putResponseUpdatedAt(data) {
544
+ const v = data?.claude_md_updated_at
545
+ return typeof v === "string" && v ? v : null
546
+ }
547
+
146
548
  async function readLocalConfigMd(targetDir, configFilename) {
147
549
  try {
148
550
  return await fs.readFile(path.join(targetDir, configPaths(configFilename).filename), "utf-8")
@@ -161,13 +563,38 @@ async function readMeta(targetDir, configFilename) {
161
563
  return await readJsonOrNull(metaPath)
162
564
  }
163
565
 
566
+ /**
567
+ * 一時ファイルへ書いてから rename する(同一ディレクトリ内なので rename は原子的)。
568
+ *
569
+ * 途中で電源断・プロセス kill が起きても、**半端な内容のファイルが見えない**。
570
+ * meta / base はどちらも「次回 sync の判定材料」なので、壊れた中身が残ると
571
+ * 判定そのものが狂う。
572
+ * ⚠️ 一時ファイル名は `.cockpit-sync/` 直下に作るが、`bakNamePattern` は
573
+ * `makeBakName` の形にしか一致しないため .bak の FIFO 削除には巻き込まれない。
574
+ */
575
+ async function writeFileAtomic(filePath, content) {
576
+ const tmp = `${filePath}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 8)}`
577
+ try {
578
+ // ⚠️ writeFile と rename を同じ try に入れること。writeFile は「一時ファイルを
579
+ // 作って一部を書いた後」に ENOSPC / quota / I/O エラーで失敗しうるので、
580
+ // cleanup を rename の catch にだけ置くと **一時ファイルが残る**。再試行のたびに
581
+ // 別名で増えるため、ディスク枯渇からの回復をさらに難しくする。
582
+ await fs.writeFile(tmp, content, "utf-8")
583
+ await fs.rename(tmp, filePath)
584
+ } catch (err) {
585
+ // rename 成功後はここへ来ない(=確定済みファイルを cleanup 対象にしない)。
586
+ // cleanup 自体の失敗で元の書込エラーを **上書きしない** よう握り潰す。
587
+ await fs.unlink(tmp).catch(() => {})
588
+ throw err
589
+ }
590
+ }
591
+
164
592
  async function writeMeta(targetDir, meta, configFilename) {
165
593
  const metaDir = path.join(targetDir, META_DIR)
166
594
  await fs.mkdir(metaDir, { recursive: true })
167
- await fs.writeFile(
595
+ await writeFileAtomic(
168
596
  path.join(metaDir, configPaths(configFilename).metaFile),
169
597
  JSON.stringify(meta, null, 2),
170
- "utf-8",
171
598
  )
172
599
  }
173
600
 
@@ -185,7 +612,88 @@ async function writeBaseBody(targetDir, body, configFilename) {
185
612
  if (body == null) return
186
613
  const metaDir = path.join(targetDir, META_DIR)
187
614
  await fs.mkdir(metaDir, { recursive: true })
188
- await fs.writeFile(path.join(metaDir, configPaths(configFilename).baseFile), body, "utf-8")
615
+ await writeFileAtomic(path.join(metaDir, configPaths(configFilename).baseFile), body)
616
+ }
617
+
618
+ /** `.cockpit-sync/<name>.base` を読む。無ければ null。 */
619
+ async function readBaseBody(targetDir, configFilename) {
620
+ try {
621
+ return await fs.readFile(
622
+ path.join(targetDir, META_DIR, configPaths(configFilename).baseFile),
623
+ "utf-8",
624
+ )
625
+ } catch {
626
+ return null
627
+ }
628
+ }
629
+
630
+ /**
631
+ * 同期成功時の状態を **base → meta の順で** 書く(成功経路はすべてこれを通す)。
632
+ *
633
+ * ⚠️ 2 ファイルの書込は原子的にできない。**meta は「判定の commit marker」**なので、
634
+ * meta を先に書くと base の書込が失敗しても「もう同期済み」と見なされ、次回 sync は
635
+ * 通常 noop に入って **base が壊れたまま永久に固定される**(base が誤って
636
+ * ディレクトリになっている / ディスク枯渇 / 個別ファイルの権限などで実際に起きる)。
637
+ *
638
+ * base を先に書けば、失敗しても meta は古いままなので、次回 sync が変化 / 収束を
639
+ * 検出して丸ごとやり直せる。逆順で生まれる「base だけ新しい」中間状態は、
640
+ * 次回 sync の再収束で解消する(同期の判定は meta しか見ないため無害)。
641
+ *
642
+ * meta には `baseHash`(=いま書いた base の hash)を残す。読み手(session-end の
643
+ * 3-way merge / 下の noop 経路)が「disk 上の base が meta の言うとおりの中身か」を
644
+ * 突き合わせられるようにするため。
645
+ */
646
+ async function writeSyncState(targetDir, { body, meta }, configFilename) {
647
+ await writeBaseBody(targetDir, body, configFilename)
648
+ await writeMeta(
649
+ targetDir,
650
+ body == null ? { ...meta } : { ...meta, baseHash: sha256(body) },
651
+ configFilename,
652
+ )
653
+ }
654
+
655
+ /**
656
+ * meta の `baseHash` と disk 上の base を突き合わせ、食い違っていれば base を書き直す。
657
+ *
658
+ * 直せるのは「いまローカルにある本文が、meta が指す同期済み本文と一致している」ときだけ
659
+ * (= base に入るべき中身が確定しているとき)。それ以外は触らない — 中身が確定しない
660
+ * まま書くと、3-way merge の base に嘘を置くことになる。
661
+ *
662
+ * @returns {Promise<boolean>} 書き直したら true
663
+ */
664
+ async function repairBaseIfInconsistent({
665
+ targetDir,
666
+ configFilename,
667
+ meta,
668
+ localBody,
669
+ localCurrentHash,
670
+ logger,
671
+ dirName,
672
+ }) {
673
+ // baseHash は writeSyncState が付ける。旧バージョンが書いた meta には無いので、
674
+ // その場合は判定材料が無い=触らない(勝手に書き直さない)。
675
+ if (!meta || typeof meta.baseHash !== "string" || !meta.baseHash) return false
676
+ if (localBody == null || localCurrentHash !== meta.lastSyncedHash) return false
677
+ const base = await readBaseBody(targetDir, configFilename)
678
+ if (base !== null && sha256(base) === meta.baseHash) return false // 整合している
679
+ try {
680
+ await writeSyncState(
681
+ targetDir,
682
+ { body: localBody, meta: { ...meta, lastSyncedAt: nowIso() } },
683
+ configFilename,
684
+ )
685
+ } catch (err) {
686
+ logger?.warn?.(
687
+ { dirName, err: err?.message || String(err) },
688
+ "syncClaudeMdWithHub: base repair failed (will retry next sync)",
689
+ )
690
+ return false
691
+ }
692
+ logger?.warn?.(
693
+ { dirName, base_missing: base === null },
694
+ "syncClaudeMdWithHub: base file was inconsistent with meta; rewrote it",
695
+ )
696
+ return true
189
697
  }
190
698
 
191
699
  /**
@@ -203,9 +711,12 @@ async function backupLocalConfigMd(targetDir, body, kind, configFilename) {
203
711
  const bakName = makeBakName(prefix)
204
712
  await fs.writeFile(path.join(metaDir, bakName), body, "utf-8")
205
713
  // 同 prefix の .bak を新しい順に BAK_KEEP 件まで残す。
714
+ // 対象は「hub-agent が makeBakName で作った、この kind の .bak」だけに限定する
715
+ // (conflict 退避や手作業で置かれた異物を巻き添えにしないため。bakNamePattern 参照)。
716
+ const pattern = bakNamePattern(prefix)
206
717
  const entries = await fs.readdir(metaDir).catch(() => [])
207
718
  const sameKind = entries
208
- .filter((n) => n.startsWith(`${prefix}.`) && n.endsWith(".bak"))
719
+ .filter((n) => pattern.test(n))
209
720
  .sort() // 名前にタイムスタンプを含むので辞書順 ≒ 時系列順
210
721
  .reverse() // 新しい順
211
722
  const obsolete = sameKind.slice(BAK_KEEP)
@@ -226,6 +737,8 @@ async function backupLocalConfigMd(targetDir, body, kind, configFilename) {
226
737
  * @param {string} [args.accessToken] 省略時は ~/.claude/.hub_token.json から
227
738
  * @param {object} [args.logger]
228
739
  * @param {Function} [args.fetchImpl] テスト注入用
740
+ * @param {number} [args.requestTimeoutMs] Hub API 1 往復の期限(既定
741
+ * `HUB_REQUEST_TIMEOUT_MS`)。テスト注入・低速環境の調整用。
229
742
  * @returns {Promise<{ action: 'pull'|'push'|'conflict'|'noop'|'skipped', reason?: string }>}
230
743
  */
231
744
  export async function syncClaudeMdWithHub({
@@ -236,6 +749,7 @@ export async function syncClaudeMdWithHub({
236
749
  logger,
237
750
  fetchImpl,
238
751
  configFilename = "CLAUDE.md",
752
+ requestTimeoutMs = HUB_REQUEST_TIMEOUT_MS,
239
753
  } = {}) {
240
754
  if (!targetDir) throw new Error("syncClaudeMdWithHub requires targetDir")
241
755
  if (!dirName) throw new Error("syncClaudeMdWithHub requires dirName")
@@ -255,6 +769,7 @@ export async function syncClaudeMdWithHub({
255
769
  dirName,
256
770
  fetchImpl,
257
771
  logger,
772
+ timeoutMs: requestTimeoutMs,
258
773
  })
259
774
  if (!hub) {
260
775
  return { action: "skipped", reason: "hub-not-resolvable" }
@@ -304,57 +819,140 @@ export async function syncClaudeMdWithHub({
304
819
  localCurrentHash != null && localCurrentHash !== meta.lastSyncedHash
305
820
  }
306
821
 
307
- // 同じ内容で書き込まれている(hash 一致)なら衝突扱いせず noop に降格。
822
+ // 同じ内容で書き込まれている(hash 一致)=内容はすでに収束している。
823
+ //
824
+ // ⚠️ ここで「noop に降格」するだけで meta を放置すると、**内容は同じなのに同期基準
825
+ // だけが古い**状態が固定化する。その後 Hub 側だけを編集すると、meta が古いせいで
826
+ // localChanged も true と判定され、hash 不一致 → conflict → 端末勝者で PUT となり
827
+ // **Hub 側の編集が消える**(実際に踏んだ経路: PUT 成功だが updated_at が取れず
828
+ // meta 未更新 → 次回この分岐 → さらに次で Hub 編集を上書き)。
829
+ // よってこの分岐は「収束を確定させる自己修復」にする。
308
830
  if (hubChanged && localChanged && hubHash && hubHash === localCurrentHash) {
309
- hubChanged = false
310
- localChanged = false
831
+ // ⚠️ GET の payload を無条件に信用しない。updated_at があり、かつ申告された
832
+ // hash が本文と実際に一致するときだけ同期基準を進める(壊れた payload で
833
+ // 基準を進めると、その後の pull / push の判定が丸ごと狂う)。
834
+ if (hubUpdatedAt && hubHash === sha256(hubBody)) {
835
+ // ⚠️ base → meta の順(writeSyncState)。meta を先に進めると、base の書込が
836
+ // 失敗しても「同期済み」と見なされ、次回 sync が通常 noop に入って
837
+ // base が壊れたまま固定される。
838
+ await writeSyncState(targetDir, {
839
+ body: hubBody,
840
+ meta: {
841
+ lastSyncedHubUpdatedAt: hubUpdatedAt,
842
+ lastSyncedHash: hubHash,
843
+ lastSyncedAt: nowIso(),
844
+ },
845
+ }, configFilename)
846
+ logger?.info?.(
847
+ { dirName },
848
+ "syncClaudeMdWithHub: converged (meta/base repaired to hub state)",
849
+ )
850
+ return { action: "noop", reason: "converged" }
851
+ }
852
+ logger?.warn?.(
853
+ { dirName, has_updated_at: !!hubUpdatedAt },
854
+ "syncClaudeMdWithHub: converged but hub payload is inconsistent; meta not advanced",
855
+ )
856
+ return { action: "noop", reason: "converged-untrusted-payload" }
311
857
  }
312
858
 
313
859
  // 4) アクション決定
314
860
  if (!hubChanged && !localChanged) {
315
- return { action: "noop" }
861
+ // 変化は無いが、**base が meta の言うとおりの中身か**だけ確かめる。
862
+ // base の書込だけが失敗した中間状態(base → meta の順で書くので meta が古く
863
+ // 残る)は次回の変化検出で解消するが、同期後に base が壊された / 消された
864
+ // ケースはここでしか気づけない。3-way merge が壊れた base を使うのを防ぐ。
865
+ const repaired = await repairBaseIfInconsistent({
866
+ targetDir,
867
+ configFilename,
868
+ meta,
869
+ localBody,
870
+ localCurrentHash,
871
+ logger,
872
+ dirName,
873
+ })
874
+ return repaired ? { action: "noop", reason: "base-repaired" } : { action: "noop" }
316
875
  }
317
876
 
318
877
  if (hubChanged && !localChanged) {
319
878
  // pull: ローカルを .bak 退避 → Hub の本文で上書き
320
879
  await backupLocalConfigMd(targetDir, localBody, "normal", configFilename)
321
880
  await writeLocalConfigMd(targetDir, hubBody, configFilename)
322
- await writeMeta(targetDir, {
323
- lastSyncedHubUpdatedAt: hubUpdatedAt,
324
- lastSyncedHash: hubHash || sha256(hubBody),
325
- lastSyncedAt: nowIso(),
326
- }, configFilename)
327
881
  // 3-way merge 用 base: pull 直後はローカル == Hub なので Hub 本文を base に。
328
- await writeBaseBody(targetDir, hubBody, configFilename)
882
+ // base → meta の順で書く(writeSyncState の JSDoc 参照)。
883
+ await writeSyncState(targetDir, {
884
+ body: hubBody,
885
+ meta: {
886
+ lastSyncedHubUpdatedAt: hubUpdatedAt,
887
+ lastSyncedHash: hubHash || sha256(hubBody),
888
+ lastSyncedAt: nowIso(),
889
+ },
890
+ }, configFilename)
329
891
  logger?.info?.({ dirName }, "syncClaudeMdWithHub: pull")
330
892
  return { action: "pull" }
331
893
  }
332
894
 
333
895
  if (!hubChanged && localChanged) {
334
896
  // push: Hub に PUT。成功時にメタ更新。
335
- const ok = await putHubClaudeMd({
897
+ const put = await putHubClaudeMd({
336
898
  hubUrl: url,
337
899
  accessToken: token,
338
900
  dirName,
339
901
  body: localBody || "",
902
+ // 楽観ロック: この sync が GET で読んだ時点の hash を渡す。読んだ後に Hub が
903
+ // 動いていれば backend が 409 を返すので、盲目的な上書きにならない。
904
+ expectedHash: hubHash || undefined,
340
905
  fetchImpl,
341
906
  logger,
907
+ timeoutMs: requestTimeoutMs,
342
908
  })
343
- if (!ok) {
909
+ if (!put.ok) {
910
+ if (put.status === 409) {
911
+ // 409 = 読んだ後に Hub 側が動いた。通信失敗ではないので理由を分ける。
912
+ // メタは更新せず、次回 sync で hubChanged 込みで判定し直す。
913
+ logger?.warn?.(
914
+ { dirName },
915
+ "syncClaudeMdWithHub: push rejected by optimistic lock (hub moved); retry next sync",
916
+ )
917
+ return { action: "skipped", reason: "hub-put-stale" }
918
+ }
919
+ if (put.error?.error === "config_md_kind_mismatch") {
920
+ // CLI 種別の取り違え(AGENTS.md の内容を CLAUDE.md 正本へ送った等)。
921
+ // 同じ本文を再送しても永久に通らないので、リトライ前提の通信失敗と混ぜない。
922
+ logger?.warn?.(
923
+ { dirName, ...put.error },
924
+ "syncClaudeMdWithHub: push rejected (config md kind mismatch); resending the same body will keep failing",
925
+ )
926
+ return { action: "skipped", reason: "hub-put-kind-mismatch" }
927
+ }
344
928
  return { action: "skipped", reason: "hub-put-failed" }
345
929
  }
346
- await writeMeta(targetDir, {
347
- // PUT 後の Hub.updated_at は再度 GET しないと厳密には取れないため、
348
- // 「ローカル hash と Hub.updated_at は別途次回 sync で再同期」する設計。
349
- // ここでは「今 push したぶんは Hub に反映済み」として、現在のローカル
350
- // hash と「今の時刻」をメタに記録する(次回 sync 時に再判定可能)。
351
- lastSyncedHubUpdatedAt: nowIso(),
352
- lastSyncedHash: localCurrentHash,
353
- lastSyncedAt: nowIso(),
354
- }, configFilename)
930
+ // 次回 sync の hubChanged 判定は lastSyncedHubUpdatedAt との文字列比較なので、
931
+ // 端末の時刻ではなく **サーバーが返した実際の値** を入れないと必ず不一致になる
932
+ // (Django はマイクロ秒 6 桁 / nowIso() はミリ秒 3 桁)。
933
+ const putUpdatedAt = putResponseUpdatedAt(put.data)
934
+ if (!putUpdatedAt) {
935
+ // 推測値を書かない。書かなければ次回 sync でやり直せる。
936
+ logger?.warn?.(
937
+ { dirName },
938
+ "syncClaudeMdWithHub: PUT succeeded but response had no claude_md_updated_at; meta not updated",
939
+ )
940
+ return { action: "skipped", reason: "hub-put-missing-updated-at" }
941
+ }
355
942
  // 3-way merge 用 base: push 直後は Hub の正本がローカルと一致するので、
356
- // 次回の base は今 push した本文 (= localBody)。
357
- await writeBaseBody(targetDir, localBody || "", configFilename)
943
+ // 次回の base は今 push した本文 (= localBody)。base → meta の順で書く。
944
+ await writeSyncState(targetDir, {
945
+ body: localBody || "",
946
+ meta: {
947
+ // lastSyncedHubUpdatedAt は「Hub 側の版を指す識別子」で、次回 sync の
948
+ // hubChanged 判定に使う。必ずサーバー値を入れること。
949
+ lastSyncedHubUpdatedAt: putUpdatedAt,
950
+ lastSyncedHash: localCurrentHash,
951
+ // lastSyncedAt は「この端末がいつ同期したか」の記録で、判定には使わない。
952
+ // 用途が違うので putUpdatedAt と混同しないこと。
953
+ lastSyncedAt: nowIso(),
954
+ },
955
+ }, configFilename)
358
956
  logger?.info?.({ dirName }, "syncClaudeMdWithHub: push")
359
957
  return { action: "push" }
360
958
  }
@@ -362,25 +960,56 @@ export async function syncClaudeMdWithHub({
362
960
  // hubChanged && localChanged → 衝突。端末側を勝者にして Hub に PUT。
363
961
  // Hub の取り込み内容は .bak.conflict に退避(後で人手マージできるよう)。
364
962
  await backupLocalConfigMd(targetDir, hubBody, "conflict", configFilename)
365
- const ok = await putHubClaudeMd({
963
+ const put = await putHubClaudeMd({
366
964
  hubUrl: url,
367
965
  accessToken: token,
368
966
  dirName,
369
967
  body: localBody || "",
968
+ // push 経路と同じく、GET 時点の hash で楽観ロックを効かせる。
969
+ expectedHash: hubHash || undefined,
370
970
  fetchImpl,
371
971
  logger,
972
+ timeoutMs: requestTimeoutMs,
372
973
  })
373
- if (!ok) {
974
+ if (!put.ok) {
374
975
  // PUT 失敗時はメタを更新しない(次回再試行)。ローカルは無傷。
976
+ if (put.status === 409) {
977
+ logger?.warn?.(
978
+ { dirName },
979
+ "syncClaudeMdWithHub: conflict push rejected by optimistic lock (hub moved); retry next sync",
980
+ )
981
+ return { action: "skipped", reason: "hub-put-stale-in-conflict" }
982
+ }
983
+ if (put.error?.error === "config_md_kind_mismatch") {
984
+ // push 経路と同じ理由(再送しても通らない拒否)。通信失敗と区別する。
985
+ logger?.warn?.(
986
+ { dirName, ...put.error },
987
+ "syncClaudeMdWithHub: conflict push rejected (config md kind mismatch); resending the same body will keep failing",
988
+ )
989
+ return { action: "skipped", reason: "hub-put-kind-mismatch-in-conflict" }
990
+ }
375
991
  return { action: "skipped", reason: "hub-put-failed-in-conflict" }
376
992
  }
377
- await writeMeta(targetDir, {
378
- lastSyncedHubUpdatedAt: nowIso(),
379
- lastSyncedHash: localCurrentHash,
380
- lastSyncedAt: nowIso(),
381
- }, configFilename)
993
+ const putUpdatedAt = putResponseUpdatedAt(put.data)
994
+ if (!putUpdatedAt) {
995
+ logger?.warn?.(
996
+ { dirName },
997
+ "syncClaudeMdWithHub: conflict PUT succeeded but response had no claude_md_updated_at; meta not updated",
998
+ )
999
+ return { action: "skipped", reason: "hub-put-missing-updated-at-in-conflict" }
1000
+ }
382
1001
  // 3-way merge 用 base: conflict push 後の Hub 正本は端末側本文 (= localBody)。
383
- await writeBaseBody(targetDir, localBody || "", configFilename)
1002
+ // base → meta の順で書く(writeSyncState の JSDoc 参照)。
1003
+ await writeSyncState(targetDir, {
1004
+ body: localBody || "",
1005
+ meta: {
1006
+ // push 経路と同じ理由でサーバー値を使う(端末時刻を入れると次回必ず hubChanged)。
1007
+ lastSyncedHubUpdatedAt: putUpdatedAt,
1008
+ lastSyncedHash: localCurrentHash,
1009
+ // lastSyncedAt は端末側の同期時刻。判定には使わない。
1010
+ lastSyncedAt: nowIso(),
1011
+ },
1012
+ }, configFilename)
384
1013
  logger?.warn?.(
385
1014
  { dirName },
386
1015
  "syncClaudeMdWithHub: conflict (terminal wins); hub body saved to .bak.conflict",
@@ -391,7 +1020,19 @@ export async function syncClaudeMdWithHub({
391
1020
  // メタ操作・ハッシュ計算は ensureClaudeMd 側 (Hub pull 初回配信) でも使うため
392
1021
  // named export する。同じファイルパス・同じスキーマで管理することで、初回配信と
393
1022
  // 以降の sync で食い違いを起こさない。
394
- export { sha256, readMeta, writeMeta, writeBaseBody, nowIso, configPaths }
1023
+ // ⚠️ 新規の呼び出し元は `writeMeta` / `writeBaseBody` を個別に呼ばず
1024
+ // **`writeSyncState` を使うこと**(base → meta の順序と baseHash の付与を一箇所に
1025
+ // 集約するため。順序を誤ると base 書込失敗時に永久に再修復されない)。
1026
+ export {
1027
+ sha256,
1028
+ readMeta,
1029
+ writeMeta,
1030
+ writeBaseBody,
1031
+ writeSyncState,
1032
+ readBaseBody,
1033
+ nowIso,
1034
+ configPaths,
1035
+ }
395
1036
 
396
1037
  // 内部テスト用エクスポート
397
1038
  export const _internals = {
@@ -399,7 +1040,13 @@ export const _internals = {
399
1040
  readMeta,
400
1041
  writeMeta,
401
1042
  writeBaseBody,
1043
+ writeSyncState,
1044
+ readBaseBody,
402
1045
  backupLocalConfigMd,
403
1046
  BAK_KEEP,
404
1047
  configPaths,
1048
+ // 「生成する名前」と「FIFO 対象を選ぶ正規表現」が食い違うと保持ロジックが静かに
1049
+ // 壊れるため、両者を突き合わせるテストから参照できるように公開する。
1050
+ makeBakName,
1051
+ bakNamePattern,
405
1052
  }