@cocorograph/hub-agent 0.8.0 → 0.8.1
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/claude-stream-bridge.mjs +183 -10
- package/src/main.mjs +98 -5
package/package.json
CHANGED
|
@@ -85,6 +85,42 @@ const CHAT_RESIDENT_ENABLED = process.env.HUB_AGENT_CHAT_RESIDENT !== "0"
|
|
|
85
85
|
const CHAT_RESIDENT_RESUME_ENABLED =
|
|
86
86
|
process.env.HUB_AGENT_CHAT_RESIDENT_RESUME !== "0"
|
|
87
87
|
|
|
88
|
+
/** 受理済み input_id リングの上限 (T06603, 2026-07-25)。queue snapshot に載せて
|
|
89
|
+
* browser の「実行完了 vs 未配達」判定に使う。1 セッションの送信待ちは実運用で
|
|
90
|
+
* 数件〜十数件なので 256 あれば「表示中の楽観チップが評価対象から溢れる」ことは無い。
|
|
91
|
+
* 溢れた場合も誤って消すのではなく「未配達 (要再送)」側に倒れるため安全側。 */
|
|
92
|
+
const ACCEPTED_INPUT_ID_RING_MAX = 256
|
|
93
|
+
|
|
94
|
+
/** キュー snapshot の世代 (T06603, 2026-07-25)。
|
|
95
|
+
*
|
|
96
|
+
* 版番号 (seq) は **セッションオブジェクト単位**の連番なので、同一プロセス内でも
|
|
97
|
+
* session が作り直される (resume / 再 attach) と 1 に戻る。一方 backend の写しキャッシュと
|
|
98
|
+
* browser は単調な版を覚えているため、seq だけで比較すると新しい snapshot が永久に
|
|
99
|
+
* 「古い」と判定されて **表示が凍り付く**。そこで seq の上位に世代を置く。
|
|
100
|
+
*
|
|
101
|
+
* ⚠️ **この値が保証するのは「同一プロセス内」の順序だけ** (cross-review round2)。値は
|
|
102
|
+
* 壁時計 ms で、プロセス内では必ず前回より大きい値を返す (同一 ms の衝突回避 + 時計が
|
|
103
|
+
* 巻き戻っても +1 で単調) が、**プロセス再起動をまたぐ順序は保証しない** (NTP のステップ
|
|
104
|
+
* 補正・手動時刻変更・別ホスト間の時計差で逆転しうる)。
|
|
105
|
+
*
|
|
106
|
+
* そしてその保証は **不要** である (cross-review 3 巡目で設計確定)。browser は
|
|
107
|
+
* ① 適用する queue_state を own stream の authoritative コピーだけに限る
|
|
108
|
+
* ② attach (claude.ready) のたびに版番号の基準点を外す
|
|
109
|
+
* という 2 点により、版番号空間を **1 回の attach の中に閉じている**。agent が再起動すれば
|
|
110
|
+
* browser は必ず再 attach するので (agent.status online → reattachSameSession)、
|
|
111
|
+
* プロセスを跨いで版を比較する場面が存在しない。
|
|
112
|
+
*
|
|
113
|
+
* ゆえにここで壁時計以上の保証を作り込もうとしないこと。プロセス間の全順序を求めると
|
|
114
|
+
* 永続採番 (DB sequence) と session 所有権リースが必要になり、表示のために分散合意に近い
|
|
115
|
+
* 機構を抱えることになる (実際に版番号方式では「時計後退で永久 reject」「非権威 producer に
|
|
116
|
+
* よる cache 汚染」の穴が連続して見つかり、設計ごと撤回した)。 */
|
|
117
|
+
let _lastQueueGeneration = 0
|
|
118
|
+
function nextQueueGeneration() {
|
|
119
|
+
const now = Date.now()
|
|
120
|
+
_lastQueueGeneration = now > _lastQueueGeneration ? now : _lastQueueGeneration + 1
|
|
121
|
+
return _lastQueueGeneration
|
|
122
|
+
}
|
|
123
|
+
|
|
88
124
|
/** MCP control 専用 query のアイドル破棄時間 (2026-06-03)。MCP サーバーは Claude Code
|
|
89
125
|
* 設定 (アカウント単位) で全セッション共有のため、チャットセッションとは独立した
|
|
90
126
|
* 「control 専用 query」を 1 本だけ遅延起動して状態取得・再認証・再接続を行う。
|
|
@@ -434,6 +470,24 @@ export class ClaudeStreamSession {
|
|
|
434
470
|
/** 改修3: 直近 browser へ通知した queue 署名 (件数 + id 列)。変化時のみ emit する。
|
|
435
471
|
* 空キューの署名 ("0:") で初期化し、空→空の冗長 emit を抑止する。 */
|
|
436
472
|
this._lastEmittedQueueSig = "0:"
|
|
473
|
+
/** キュー snapshot の版番号 (T06603, 2026-07-25)。emit するたびに単調増加する。
|
|
474
|
+
* browser は「受け取った snapshot が手元より新しいときだけ適用する」ことで、
|
|
475
|
+
* ①遅延した relay / backend キャッシュの古い snapshot による巻き戻し
|
|
476
|
+
* ②stream group と session group の二重配送
|
|
477
|
+
* を無害化できる。**欠落の検知には使わない** (snapshot は全状態を含むので、
|
|
478
|
+
* 落ちた版があっても次に届いた版を適用すれば正しくなる)。落ちたまま次が来ない
|
|
479
|
+
* ケースの救済は browser 側の pull (claude.queue.request) が担う。 */
|
|
480
|
+
this._queueStateSeq = 0
|
|
481
|
+
/** この session オブジェクトの世代 (T06603)。上記 nextQueueGeneration の説明を参照。
|
|
482
|
+
* **同一プロセス内の順序のみ**を保証する (プロセス間は backend の接続フェンスが担う)。 */
|
|
483
|
+
this._queueGeneration = nextQueueGeneration()
|
|
484
|
+
/** 配達確認の再表明用 (T06603): この session が受理 (キュー投入 or 直接配送) した
|
|
485
|
+
* input_id の直近リング。`claude.input.ack` は一度きりのユニキャストで、WS 切断が
|
|
486
|
+
* 30s を超えると ws-client の ctrl バッファから破棄され二度と再送されない。その結果
|
|
487
|
+
* browser は「実際には届いて実行されたのに未配達扱い」で送信待ちチップを固着させる。
|
|
488
|
+
* snapshot にこのリングを載せることで、ack を取りこぼした browser も
|
|
489
|
+
* 「キューに無い + 受理済み = 実行完了」と判定でき、pull だけで自己回復できる。 */
|
|
490
|
+
this._acceptedInputIds = []
|
|
437
491
|
|
|
438
492
|
/** 連携カード種別タグ (Phase 2, 2026-07-23): ask-sibling/orchestrate 等の **別セッションからの
|
|
439
493
|
* 注入** を、受け手画面で「青い自分バブル」ではなく専用の連携依頼カードで表示させるため、
|
|
@@ -950,6 +1004,19 @@ export class ClaudeStreamSession {
|
|
|
950
1004
|
* `accepted:false` のときは呼び出し元 (ClaudeStreamBridge.input) が nack を組み立てる。
|
|
951
1005
|
*/
|
|
952
1006
|
async sendMessage(message, opts = {}) {
|
|
1007
|
+
const result = await this._sendMessageInner(message, opts)
|
|
1008
|
+
// 配達確認の再表明 (T06603): 受理 (= ack を返す) の記録を **1 箇所に集約**する。
|
|
1009
|
+
// 投入経路は busy キュー / resident 直行 / fresh start / per-message と 4 通りあり、
|
|
1010
|
+
// 各経路に散らすと新経路追加のたびに記録漏れが起き、browser が「未配達」と誤判定して
|
|
1011
|
+
// チップを固着させる。ack を返す条件 (accepted) と同じ場所で 1 回だけ記録する。
|
|
1012
|
+
const inputId =
|
|
1013
|
+
typeof opts?.input_id === "string" && opts.input_id ? opts.input_id : null
|
|
1014
|
+
if (inputId && result?.accepted) this._recordAcceptedInput(inputId)
|
|
1015
|
+
return result
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
/** sendMessage 本体 (受理記録は呼び出し元の sendMessage が funnel で行う)。 */
|
|
1019
|
+
async _sendMessageInner(message, opts = {}) {
|
|
953
1020
|
if (this._closed) return { accepted: false, reason: "queue_dead" }
|
|
954
1021
|
const prompt = extractPromptText(message)
|
|
955
1022
|
if (!prompt) return { accepted: false, reason: "empty_message" }
|
|
@@ -1227,7 +1294,7 @@ export class ClaudeStreamSession {
|
|
|
1227
1294
|
return
|
|
1228
1295
|
}
|
|
1229
1296
|
const next = this._pendingMessages.shift()
|
|
1230
|
-
this._emitQueueState([next.text])
|
|
1297
|
+
this._emitQueueState([next.text], { startedIds: [next.id] })
|
|
1231
1298
|
this._runPerMessage(next.text, { ultracode: next.ultracode === true }).catch(
|
|
1232
1299
|
(err) => {
|
|
1233
1300
|
this.logger?.error(
|
|
@@ -1245,10 +1312,37 @@ export class ClaudeStreamSession {
|
|
|
1245
1312
|
// (楽観チップの id と一致させ、確認前キャンセルを可能にする)。無ければ従来の qN。
|
|
1246
1313
|
const id =
|
|
1247
1314
|
typeof inputId === "string" && inputId ? inputId : `q${++this._queueSeq}`
|
|
1315
|
+
// 二重投入ガード (T06603, 2026-07-25): 同一 input_id が既にキューに居るなら積まない。
|
|
1316
|
+
// main.mjs の in-flight dedup (claudeInflightByInputId) で通常は到達しないが、
|
|
1317
|
+
// 「in-flight レジストリから外れた直後 (settle 済み・seen 登録前) の再送」等の窓では
|
|
1318
|
+
// ここが最後の砦になる。id 重複を許すと ①同じ本文が 2 回実行される
|
|
1319
|
+
// ②drain 後も同 id の項目がキューに残り送信待ちチップが消えない、の二重障害になる。
|
|
1320
|
+
if (this._pendingMessages.some((m) => m.id === id)) {
|
|
1321
|
+
this.logger?.info(
|
|
1322
|
+
{ stream_id: this.stream_id, id },
|
|
1323
|
+
"duplicate input_id already queued, skipped",
|
|
1324
|
+
)
|
|
1325
|
+
return id
|
|
1326
|
+
}
|
|
1248
1327
|
this._pendingMessages.push({ id, text: prompt, ultracode: ultracode === true })
|
|
1249
1328
|
return id
|
|
1250
1329
|
}
|
|
1251
1330
|
|
|
1331
|
+
/** 配達確認の再表明 (T06603): 受理済み input_id をリングへ記録する。
|
|
1332
|
+
* snapshot の `accepted` として browser へ載せ、ack 喪失時の自己回復に使う。 */
|
|
1333
|
+
_recordAcceptedInput(inputId) {
|
|
1334
|
+
if (typeof inputId !== "string" || !inputId) return
|
|
1335
|
+
const at = this._acceptedInputIds.indexOf(inputId)
|
|
1336
|
+
if (at !== -1) this._acceptedInputIds.splice(at, 1)
|
|
1337
|
+
this._acceptedInputIds.push(inputId)
|
|
1338
|
+
if (this._acceptedInputIds.length > ACCEPTED_INPUT_ID_RING_MAX) {
|
|
1339
|
+
this._acceptedInputIds.splice(
|
|
1340
|
+
0,
|
|
1341
|
+
this._acceptedInputIds.length - ACCEPTED_INPUT_ID_RING_MAX,
|
|
1342
|
+
)
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1252
1346
|
/** キャンセル機能 (0.6.26): browser から id 指定で送信待ちメッセージを 1 件取り消す。
|
|
1253
1347
|
* 実行開始済み (既に drain された) メッセージは pending に残っていないので no-op になる。
|
|
1254
1348
|
* 削除に成功したら queue_state を再 emit して送信待ちチップを更新する。 */
|
|
@@ -1269,9 +1363,30 @@ export class ClaudeStreamSession {
|
|
|
1269
1363
|
{ stream_id: this.stream_id, id, queued: this._pendingMessages.length },
|
|
1270
1364
|
"queued message canceled",
|
|
1271
1365
|
)
|
|
1272
|
-
this._emitQueueState()
|
|
1273
1366
|
}
|
|
1274
|
-
|
|
1367
|
+
// T06603 cross-review 5 巡目: **「消えた」だけでは取消成功と実行開始済みを区別できない**。
|
|
1368
|
+
// drain の started フレームが落ちていると、browser は「キューに無い + 受理済み」で
|
|
1369
|
+
// 実行完了と判定してチップを消すが、ユーザーから見ると「取り消せた」ようにしか見えず、
|
|
1370
|
+
// しかも started 欠落で発言バブルも出ていない。ここで結末を明示する。
|
|
1371
|
+
// - pending から除去できた → cancelled
|
|
1372
|
+
// - pending に無い + 受理済み → already_started (もう走っている)
|
|
1373
|
+
// - pending に無い + 未受理 → cancelled (先着キャンセルガードを張ったので、
|
|
1374
|
+
// 後から届く同 id の input は破棄される)
|
|
1375
|
+
const outcome = removed
|
|
1376
|
+
? "cancelled"
|
|
1377
|
+
: this._acceptedInputIds.includes(id)
|
|
1378
|
+
? "already_started"
|
|
1379
|
+
: "cancelled"
|
|
1380
|
+
// ⚠️ T06603 cross-review 6 巡目: **snapshot をここで emit しない**。
|
|
1381
|
+
// 従来はここで force 再表明していたが、そのフレームは outcome 応答より先に browser へ
|
|
1382
|
+
// 届く (backend は agent の到着順に relay する)。先に届いた空 snapshot が「キューに無い
|
|
1383
|
+
// + 受理済み = 実行完了」の判定でチップを消してしまい、後続の outcome は対象不在で
|
|
1384
|
+
// no-op になる → already_started のバブル補償が発火しない。
|
|
1385
|
+
// 呼び出し元が outcome を送ってから flush() を呼ぶことで、配送順を確定させる。
|
|
1386
|
+
return {
|
|
1387
|
+
outcome,
|
|
1388
|
+
flush: () => this._emitQueueState([], { force: true }),
|
|
1389
|
+
}
|
|
1275
1390
|
}
|
|
1276
1391
|
|
|
1277
1392
|
/** 改修3: pending キューの現状を browser へ通知する (送信待ちチップ表示用)。
|
|
@@ -1285,6 +1400,12 @@ export class ClaudeStreamSession {
|
|
|
1285
1400
|
* 署名が前回と同一でも browser がライブ配信を取りこぼしている可能性があるため。 */
|
|
1286
1401
|
_emitQueueState(started = [], opts = undefined) {
|
|
1287
1402
|
const force = opts?.force === true
|
|
1403
|
+
// 実行開始した項目の input_id (started と parallel。T06603 cross-review major#2)。
|
|
1404
|
+
// 本文テキストだけだと、同一本文を連投したときに 2 通目のユーザー発言バブルが
|
|
1405
|
+
// 「既に同じ本文のバブルがある」と誤判定されて永久に出ない。id で突合させる。
|
|
1406
|
+
const startedIds = Array.isArray(opts?.startedIds)
|
|
1407
|
+
? opts.startedIds
|
|
1408
|
+
: started.map(() => null)
|
|
1288
1409
|
const count = this._pendingMessages.length
|
|
1289
1410
|
// 署名 = 件数 + id 列。件数が同じでもキャンセルで中身が変われば通知する。
|
|
1290
1411
|
const sig = `${count}:${this._pendingMessages.map((m) => m.id).join(",")}`
|
|
@@ -1311,19 +1432,47 @@ export class ClaudeStreamSession {
|
|
|
1311
1432
|
// 昇格させるため、ここで切り詰めると本文が欠ける。チップの省略表示は CSS 側で行う。
|
|
1312
1433
|
// items は id 付きでキャンセルボタンの対象特定に使う (0.6.26)。
|
|
1313
1434
|
this.onEvent?.({
|
|
1314
|
-
|
|
1315
|
-
pending: count,
|
|
1316
|
-
messages: this._pendingMessages.map((m) => m.text),
|
|
1317
|
-
items: this._pendingMessages.map((m) => ({ id: m.id, text: m.text })),
|
|
1435
|
+
...this._buildQueueSnapshot(),
|
|
1318
1436
|
started,
|
|
1319
1437
|
// started と parallel。連携注入なら 'peer'|'orchestrator'、通常は null。
|
|
1320
1438
|
startedSources,
|
|
1439
|
+
// started と parallel。キュー由来なら input_id、注入等で id が無ければ null。
|
|
1440
|
+
startedIds,
|
|
1321
1441
|
})
|
|
1322
1442
|
} catch {
|
|
1323
1443
|
/* ignore */
|
|
1324
1444
|
}
|
|
1325
1445
|
}
|
|
1326
1446
|
|
|
1447
|
+
/** キュー snapshot (T06603, 2026-07-25): 現在の権威キュー状態を 1 個のオブジェクトに
|
|
1448
|
+
* まとめる。ライブ配信 (_emitQueueState) と pull 応答 (claude.queue.request) の
|
|
1449
|
+
* **両方がこの 1 関数を通る**ため、経路によって形が食い違うことがない。
|
|
1450
|
+
* 版番号 (seq) はここで採番する = 「browser へ出す状態が 1 つ進んだ」の唯一の定義。 */
|
|
1451
|
+
_buildQueueSnapshot() {
|
|
1452
|
+
this._queueStateSeq += 1
|
|
1453
|
+
return {
|
|
1454
|
+
type: "queue_state",
|
|
1455
|
+
// 世代 (T06603 cross-review blocker): 再起動 / resume で seq が 1 に戻っても、
|
|
1456
|
+
// 受け手が「古い」と誤判定しないよう上位キーとして必ず載せる。
|
|
1457
|
+
generation: this._queueGeneration,
|
|
1458
|
+
seq: this._queueStateSeq,
|
|
1459
|
+
pending: this._pendingMessages.length,
|
|
1460
|
+
messages: this._pendingMessages.map((m) => m.text),
|
|
1461
|
+
items: this._pendingMessages.map((m) => ({ id: m.id, text: m.text })),
|
|
1462
|
+
// 受理済み input_id (直近リング)。browser は「キューに無い + ここに有る = 実行完了」
|
|
1463
|
+
// と判定して楽観チップを消し、「キューに無い + ここにも無い = 未配達」なら
|
|
1464
|
+
// 消さずに再送導線を出す (無言削除の回帰を作らないための 2 軸判定)。
|
|
1465
|
+
accepted: [...this._acceptedInputIds],
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
/** pull 型再同期 (T06603): 現在のキュー snapshot を返す (emit はしない)。
|
|
1470
|
+
* browser の claude.queue.request に対して claude.queue.response で返す。
|
|
1471
|
+
* 純粋な読み取りなので Read-plane (並行レーン) から呼んでよい。 */
|
|
1472
|
+
queueSnapshot() {
|
|
1473
|
+
return this._buildQueueSnapshot()
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1327
1476
|
/** 連携カード種別 (Phase 2, cross-review major#1): **idle 直接送信経路**用。busy→queue→drain は
|
|
1328
1477
|
* _emitQueueState([text]) を通るが、idle 直接実行 (resident fresh start / per-message 即時) は
|
|
1329
1478
|
* started を出さないため、注入の source が startedSources に載らずカード化されない。ここで
|
|
@@ -1527,7 +1676,11 @@ export class ClaudeStreamSession {
|
|
|
1527
1676
|
// codex-review minor: session が close 中 (outcome==="closed") は next を配送していない。
|
|
1528
1677
|
// started 通知 (queue_state に [next.text] を載せる) を出すと未配送メッセージが「開始済み」に
|
|
1529
1678
|
// 見えるため抑止する。closed は終端状態なので pending へ戻さず破棄でよい (session 撤去中)。
|
|
1530
|
-
|
|
1679
|
+
// T06603 (2026-07-25): ただし破棄したこと自体は通知する (started 無しの force 再表明)。
|
|
1680
|
+
// 従来は shift 済みなのに emit しないため、当該項目のチップが browser に残り続けていた。
|
|
1681
|
+
if (outcome !== "closed")
|
|
1682
|
+
this._emitQueueState([next.text], { startedIds: [next.id] })
|
|
1683
|
+
else this._emitQueueState([], { force: true })
|
|
1531
1684
|
} catch (err) {
|
|
1532
1685
|
this._busy = false
|
|
1533
1686
|
this._pendingMessages.unshift(next)
|
|
@@ -1709,7 +1862,7 @@ export class ClaudeStreamSession {
|
|
|
1709
1862
|
toSDKUserMessage(next.text),
|
|
1710
1863
|
next.ultracode === true,
|
|
1711
1864
|
)
|
|
1712
|
-
this._emitQueueState([next.text])
|
|
1865
|
+
this._emitQueueState([next.text], { startedIds: [next.id] })
|
|
1713
1866
|
}
|
|
1714
1867
|
// マスク (2026-06-23): 常駐 query 終了後 (= CLI が jsonl を掴んでいない) のこの地点で、
|
|
1715
1868
|
// closed 済みならオンディスク jsonl をマスクする。reap 経路は close() 内で実行済み。
|
|
@@ -1868,7 +2021,12 @@ export class ClaudeStreamSession {
|
|
|
1868
2021
|
close() {
|
|
1869
2022
|
this._closed = true
|
|
1870
2023
|
// 改修3: 未処理の pending を破棄 (セッション終了後に drain しないため)。
|
|
2024
|
+
const hadPending = this._pendingMessages.length > 0
|
|
1871
2025
|
this._pendingMessages = []
|
|
2026
|
+
// T06603 (2026-07-25): 破棄したことを必ず通知する。従来は無言で捨てていたため、
|
|
2027
|
+
// 「セッションが畳まれた後もチップだけが残る」表示が残留していた。破棄は状態変化なので
|
|
2028
|
+
// force で貫通させる (started=[] なのでバブル昇格は起きない)。
|
|
2029
|
+
if (hadPending) this._emitQueueState([], { force: true })
|
|
1872
2030
|
// 常駐 query は input キューを閉じて generator を終わらせ query を完走させる。
|
|
1873
2031
|
if (this._inputQueue) this._inputQueue.close()
|
|
1874
2032
|
this.abortTurn()
|
|
@@ -2932,11 +3090,26 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
2932
3090
|
const s = this.sessions.get(stream_id)
|
|
2933
3091
|
if (!s) {
|
|
2934
3092
|
this.logger?.warn({ stream_id, id }, "claude.queue.cancel but stream missing")
|
|
2935
|
-
|
|
3093
|
+
// T06603 cross-review 4/5 巡目: **結末を必ず browser へ返す**。browser はキャンセル
|
|
3094
|
+
// 要求時にチップを消さず「取消中」の印を付けて権威の確認を待つ設計なので、
|
|
3095
|
+
// 無応答だと印が外れる契機が無く「取消中」のまま固着する。
|
|
3096
|
+
return { outcome: "stream_missing", flush: () => {} }
|
|
2936
3097
|
}
|
|
2937
3098
|
return s.cancelQueued(id)
|
|
2938
3099
|
}
|
|
2939
3100
|
|
|
3101
|
+
/** pull 型再同期 (T06603, 2026-07-25): browser の claude.queue.request に答えるため、
|
|
3102
|
+
* 現在の権威キュー snapshot を返す。stream が無ければ null (browser 側は
|
|
3103
|
+
* 「まだ attach していない」として扱い、ready 後に引き直す)。 */
|
|
3104
|
+
queueSnapshot({ stream_id }) {
|
|
3105
|
+
const s = this.sessions.get(stream_id)
|
|
3106
|
+
if (!s) {
|
|
3107
|
+
this.logger?.warn({ stream_id }, "claude.queue.request but stream missing")
|
|
3108
|
+
return null
|
|
3109
|
+
}
|
|
3110
|
+
return { session_id: s.sessionId || null, snapshot: s.queueSnapshot() }
|
|
3111
|
+
}
|
|
3112
|
+
|
|
2940
3113
|
/**
|
|
2941
3114
|
* セッション停止 (graceful)。実行中ターンは中断せず完走させ、完走後に
|
|
2942
3115
|
* onReap で Map から撤去する。アイドルなら即時撤去。
|
package/src/main.mjs
CHANGED
|
@@ -327,7 +327,11 @@ export function isReadOnlyRpc(type) {
|
|
|
327
327
|
return (
|
|
328
328
|
type === "claude.history.request" ||
|
|
329
329
|
type === "claude.sessions.request" ||
|
|
330
|
-
type === "codex.sessions.request"
|
|
330
|
+
type === "codex.sessions.request" ||
|
|
331
|
+
// T06603 (2026-07-25): キュー snapshot の pull。in-memory 配列を読むだけで副作用が無く、
|
|
332
|
+
// かつ「直列キュー前方の重い処理で再同期が遅延する」と送信待ちチップの固着が長引く
|
|
333
|
+
// (再同期は固着を直すための経路なので、詰まりやすい経路に置いてはならない)。
|
|
334
|
+
type === "claude.queue.request"
|
|
331
335
|
)
|
|
332
336
|
}
|
|
333
337
|
|
|
@@ -2859,6 +2863,13 @@ const seenCodexChatInputIds = makeSeenInputIdRegistry()
|
|
|
2859
2863
|
// Promise へ合流させる (settle 時に元の .then/.catch が 1 回だけ ack/nack を出す)。
|
|
2860
2864
|
const codexInflightByInputId = new Map()
|
|
2861
2865
|
|
|
2866
|
+
// T06603 (2026-07-25): claude.input の **in-flight** dedup 用レジストリ (input_id -> Promise)。
|
|
2867
|
+
// 上の codex 版と同じ理由・同じ流儀。claude 側は sendMessage が resident query への
|
|
2868
|
+
// reconcile (applyFlagSettings の control request 往復) を await するため settle まで数秒
|
|
2869
|
+
// かかることがあり、その窓で ackWatchdog(5s) の再送が入ると `seen` 未登録のまま二重投入され、
|
|
2870
|
+
// 同一 input_id がキューに 2 件積まれて drain 後も 1 件残る (送信待ちチップが消えない)。
|
|
2871
|
+
const claudeInflightByInputId = new Map()
|
|
2872
|
+
|
|
2862
2873
|
/**
|
|
2863
2874
|
* input_id 付き pty.data (tracked 入力) の処理。write 成功で pty.input.ack を返し、
|
|
2864
2875
|
* stream 不在なら pty.error を返して browser の再 attach → 再送に繋げる。
|
|
@@ -2933,7 +2944,20 @@ export function handleClaudeChatInput(msg, ctx) {
|
|
|
2933
2944
|
ctx.client.send({ type: "claude.input.ack", stream_id, input_id })
|
|
2934
2945
|
return undefined
|
|
2935
2946
|
}
|
|
2936
|
-
|
|
2947
|
+
if (input_id && claudeInflightByInputId.has(input_id)) {
|
|
2948
|
+
// T06603 (2026-07-25): **処理中** の同一 input_id (browser の ackWatchdog が 5s で
|
|
2949
|
+
// 再送してくるが、こちらはまだ settle していない窓)。codex 側は round4 major#1 で
|
|
2950
|
+
// 対処済みだったが claude 側だけ欠けており、`seen` は settle 後にしか登録されないため
|
|
2951
|
+
// 再送がそのまま二重投入されていた (同じ本文が 2 回実行され、かつ同一 id の項目が
|
|
2952
|
+
// キューに 2 つ積まれて drain 後も 1 つ残る = 「実行されたのにキューから消えない」)。
|
|
2953
|
+
// 既存 Promise へ合流させ、ack/nack は元の 1 回だけ出す。
|
|
2954
|
+
ctx.logger?.info?.(
|
|
2955
|
+
{ stream_id, input_id },
|
|
2956
|
+
"claude.input in-flight dedup (join, no re-dispatch)",
|
|
2957
|
+
)
|
|
2958
|
+
return claudeInflightByInputId.get(input_id)
|
|
2959
|
+
}
|
|
2960
|
+
const p = ctx.claudeBridge
|
|
2937
2961
|
.input({
|
|
2938
2962
|
stream_id,
|
|
2939
2963
|
message: msg.message,
|
|
@@ -2972,6 +2996,13 @@ export function handleClaudeChatInput(msg, ctx) {
|
|
|
2972
2996
|
})
|
|
2973
2997
|
}
|
|
2974
2998
|
})
|
|
2999
|
+
.finally(() => {
|
|
3000
|
+
// in-flight レジストリから外す (成功時は seen が以降の再送を dedup、
|
|
3001
|
+
// 失敗時は再送で正当にリトライさせる)。codex 版と同じ流儀。
|
|
3002
|
+
if (input_id) claudeInflightByInputId.delete(input_id)
|
|
3003
|
+
})
|
|
3004
|
+
if (input_id) claudeInflightByInputId.set(input_id, p)
|
|
3005
|
+
return p
|
|
2975
3006
|
}
|
|
2976
3007
|
|
|
2977
3008
|
/**
|
|
@@ -3386,6 +3417,12 @@ async function dispatch(msg, ctx) {
|
|
|
3386
3417
|
// 旧 agent にはこのフラグが無く、browser は楽観アイテムの×を出さない (ゴースト
|
|
3387
3418
|
// 実行防止のフォールバック)。
|
|
3388
3419
|
supports_queue_input_id: true,
|
|
3420
|
+
// pull 型再同期 capability (T06603, 2026-07-25): この agent は
|
|
3421
|
+
// claude.queue.request に claude.queue.response で答え、queue_state に
|
|
3422
|
+
// 版番号 (seq) と受理済み input_id (accepted) を載せることを通知する。
|
|
3423
|
+
// 旧 agent はこのフラグが無く、browser は従来の「配信到達に依存する」
|
|
3424
|
+
// 挙動へフォールバックする (混在期間も安全に段階デプロイできる)。
|
|
3425
|
+
supports_queue_snapshot: true,
|
|
3389
3426
|
})
|
|
3390
3427
|
// dispatch全体はWSメッセージ順を守る直列キュー上にある。ここで履歴I/Oをawaitすると
|
|
3391
3428
|
// 同一接続のpermission/input等まで停止するため、readyの同期送信後は非同期でpushする。
|
|
@@ -3770,11 +3807,67 @@ async function dispatch(msg, ctx) {
|
|
|
3770
3807
|
}
|
|
3771
3808
|
return
|
|
3772
3809
|
}
|
|
3773
|
-
case "claude.queue.cancel":
|
|
3810
|
+
case "claude.queue.cancel": {
|
|
3774
3811
|
// 送信待ち (pending) メッセージを id 指定で取り消す (0.6.26)。
|
|
3775
|
-
|
|
3776
|
-
|
|
3812
|
+
// T06603 cross-review 4 巡目: 取り消せなかった (stream 不在) ときは browser へ
|
|
3813
|
+
// 失敗を返す。browser は要求時にチップを消さず「取消中」印を付けて権威の確認を
|
|
3814
|
+
// 待つため、無応答だと印が外れず固着する。request_id をエコーして相関させる。
|
|
3815
|
+
// T06603 cross-review 5 巡目: 結末 (cancelled / already_started / stream_missing) を
|
|
3816
|
+
// **必ず** 返す。「pending から消えた」だけでは取消成功と実行開始済みを区別できず、
|
|
3817
|
+
// 特に drain の started フレームを取りこぼしていると、browser は取消成功に見せかけた
|
|
3818
|
+
// まま実行を続行してしまう (発言バブルも欠けたまま)。request_id + input_id で相関させる。
|
|
3819
|
+
const cancelRes = {
|
|
3820
|
+
type: "claude.queue.response",
|
|
3821
|
+
stream_id: msg.stream_id,
|
|
3822
|
+
request_id: msg.request_id || null,
|
|
3823
|
+
input_id: msg.id || null,
|
|
3824
|
+
}
|
|
3825
|
+
if (!ctx.claudeBridge) {
|
|
3826
|
+
ctx.client.send({ ...cancelRes, outcome: "stream_missing" })
|
|
3827
|
+
return
|
|
3828
|
+
}
|
|
3829
|
+
const canceled = ctx.claudeBridge.cancelQueued({
|
|
3830
|
+
stream_id: msg.stream_id,
|
|
3831
|
+
id: msg.id,
|
|
3832
|
+
})
|
|
3833
|
+
// ⚠️ T06603 cross-review 6 巡目: **outcome を先に送る**。キュー snapshot の再表明を
|
|
3834
|
+
// 先に流すと、backend は到着順に relay するため browser は空 snapshot で
|
|
3835
|
+
// 「キューに無い + 受理済み = 実行完了」と判定してチップを消し、後続の outcome は
|
|
3836
|
+
// 対象不在で no-op になる (already_started のバブル補償が発火しない)。
|
|
3837
|
+
ctx.client.send({
|
|
3838
|
+
...cancelRes,
|
|
3839
|
+
outcome: canceled?.outcome || "stream_missing",
|
|
3840
|
+
})
|
|
3841
|
+
canceled?.flush?.()
|
|
3842
|
+
return
|
|
3843
|
+
}
|
|
3844
|
+
case "claude.queue.request": {
|
|
3845
|
+
// pull 型再同期 (T06603, 2026-07-25): browser が権威キュー snapshot を引きにくる。
|
|
3846
|
+
// 従来キュー表示の是正手段は「agent が turn settle 毎に force 再送する queue_state」
|
|
3847
|
+
// だけで、その配信が 1 通落ちると browser には自己修復の手段が無く送信待ちチップが
|
|
3848
|
+
// 恒久固着した。ここで「いつでも引き直せる」経路を与えるのが根治の中核。
|
|
3849
|
+
// 応答は要求元 stream へ返す (backend が cockpit_stream_<stream_id> へ relay)。
|
|
3850
|
+
const queueReq = {
|
|
3851
|
+
type: "claude.queue.response",
|
|
3852
|
+
stream_id: msg.stream_id,
|
|
3853
|
+
request_id: msg.request_id || null,
|
|
3854
|
+
}
|
|
3855
|
+
if (!ctx.claudeBridge) {
|
|
3856
|
+
ctx.client.send({ ...queueReq, error: "stream_missing" })
|
|
3857
|
+
return
|
|
3858
|
+
}
|
|
3859
|
+
const got = ctx.claudeBridge.queueSnapshot({ stream_id: msg.stream_id })
|
|
3860
|
+
if (!got) {
|
|
3861
|
+
ctx.client.send({ ...queueReq, error: "stream_missing" })
|
|
3862
|
+
return
|
|
3863
|
+
}
|
|
3864
|
+
ctx.client.send({
|
|
3865
|
+
...queueReq,
|
|
3866
|
+
session_id: got.session_id,
|
|
3867
|
+
event: got.snapshot,
|
|
3868
|
+
})
|
|
3777
3869
|
return
|
|
3870
|
+
}
|
|
3778
3871
|
case "claude.detach":
|
|
3779
3872
|
if (!ctx.claudeBridge) return
|
|
3780
3873
|
ctx.claudeBridge.detach({ stream_id: msg.stream_id })
|