@cocorograph/hub-agent 0.8.97 → 0.8.98
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 +2 -2
- package/src/main.mjs +35 -4
- package/src/seen-input-id-store.mjs +66 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cocorograph/hub-agent",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.98",
|
|
4
4
|
"description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの Claude / Codex チャットと tmux セッションを中継する。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "UNLICENSED",
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
},
|
|
19
19
|
"scripts": {
|
|
20
20
|
"start": "node bin/hub-agent.mjs start",
|
|
21
|
-
"test": "node --test --test-concurrency=1 test/*.test.mjs",
|
|
21
|
+
"test": "node --import ./test/setup-env.mjs --test --test-concurrency=1 test/*.test.mjs",
|
|
22
22
|
"check:publish-on-main": "node scripts/check-publish-on-main.mjs",
|
|
23
23
|
"prepublishOnly": "node scripts/check-publish-on-main.mjs && npm test"
|
|
24
24
|
},
|
package/src/main.mjs
CHANGED
|
@@ -50,6 +50,7 @@ import { WsClient } from "./ws-client.mjs"
|
|
|
50
50
|
import { DispatchScheduler } from "./dispatch-scheduler.mjs"
|
|
51
51
|
import { noteRestoreStop, restoreSessions, sendBridgeMessage } from "./session-restore.mjs"
|
|
52
52
|
import { AsyncTtlCache } from "./async-ttl-cache.mjs"
|
|
53
|
+
import { makePersistentSeenInputIdRegistry } from "./seen-input-id-store.mjs"
|
|
53
54
|
import { ClaudeStreamBridge } from "./claude-stream-bridge.mjs"
|
|
54
55
|
import { installClaudeDisplayMask } from "./claude-display-mask.mjs"
|
|
55
56
|
import { CodexMcpControl } from "./codex-mcp-control.mjs"
|
|
@@ -1179,6 +1180,11 @@ export async function startDaemon({ version, claudeSdk } = {}) {
|
|
|
1179
1180
|
restartCoordinator?.stop?.()
|
|
1180
1181
|
claudeBridge?.shutdown?.()
|
|
1181
1182
|
codexBridge?.shutdown?.()
|
|
1183
|
+
// 受理済み input_id の保存を待ってから exit する(再起動後の再送で二重実行しないため)。
|
|
1184
|
+
const persistence = await waitForClaudeInputPersistence()
|
|
1185
|
+
if (persistence === "timeout") {
|
|
1186
|
+
logger.warn({ signal }, "claude input_id の保存待ちが上限に達したため終了する")
|
|
1187
|
+
}
|
|
1182
1188
|
client.stop()
|
|
1183
1189
|
// 0.6.2 fix: watchdog を解放して event loop を抜けられるようにする
|
|
1184
1190
|
clearInterval(keepaliveTimer)
|
|
@@ -2388,9 +2394,34 @@ function makeSeenInputIdRegistry(max = SEEN_INPUT_IDS_MAX) {
|
|
|
2388
2394
|
},
|
|
2389
2395
|
}
|
|
2390
2396
|
}
|
|
2391
|
-
|
|
2397
|
+
let seenClaudeChatInputIds = makePersistentSeenInputIdRegistry(SEEN_INPUT_IDS_MAX)
|
|
2398
|
+
// テストでは処理完了後に呼び、プロセス再起動時のメモリ破棄を模擬する。
|
|
2399
|
+
export function resetSeenClaudeChatInputIdsForTest() {
|
|
2400
|
+
seenClaudeChatInputIds = makePersistentSeenInputIdRegistry(SEEN_INPUT_IDS_MAX)
|
|
2401
|
+
}
|
|
2392
2402
|
const seenCodexChatInputIds = makeSeenInputIdRegistry()
|
|
2393
2403
|
|
|
2404
|
+
/**
|
|
2405
|
+
* 通常終了(SIGTERM・安全な再起動)の直前に、処理中の claude.input と受理済み ID の保存を待つ。
|
|
2406
|
+
* 保存前に exit すると、再起動後の同じ input_id の再送が Claude へ再投入されるため。
|
|
2407
|
+
* ディスクや bridge が止まっていても終了を無期限に延ばさないよう timeoutMs で打ち切る
|
|
2408
|
+
* (打ち切り・強制終了では保証しない)。reject しない。
|
|
2409
|
+
*/
|
|
2410
|
+
export async function waitForClaudeInputPersistence(timeoutMs = 2_000) {
|
|
2411
|
+
let timer
|
|
2412
|
+
const timeout = new Promise((resolve) => {
|
|
2413
|
+
timer = setTimeout(() => resolve("timeout"), timeoutMs)
|
|
2414
|
+
})
|
|
2415
|
+
const settled = Promise.allSettled([...claudeInflightByInputId.values()])
|
|
2416
|
+
.then(() => seenClaudeChatInputIds.flush())
|
|
2417
|
+
.then(() => "flushed")
|
|
2418
|
+
try {
|
|
2419
|
+
return await Promise.race([settled, timeout])
|
|
2420
|
+
} finally {
|
|
2421
|
+
clearTimeout(timer)
|
|
2422
|
+
}
|
|
2423
|
+
}
|
|
2424
|
+
|
|
2394
2425
|
// round4 major#1 (T06542): codex.input の **in-flight** dedup 用レジストリ (input_id -> Promise)。
|
|
2395
2426
|
// `seen` は settle 後にしか登録されないため、frontend の ack watchdog (5s) が bridge の barrier/RPC
|
|
2396
2427
|
// (turn/start→turn/started は数秒〜) より短く、同一 input_id が settle 前に再送されると bridge.input を
|
|
@@ -2440,7 +2471,7 @@ export function handleClaudeChatInput(msg, ctx) {
|
|
|
2440
2471
|
}
|
|
2441
2472
|
return undefined
|
|
2442
2473
|
}
|
|
2443
|
-
if (input_id && seenClaudeChatInputIds.has(input_id)) {
|
|
2474
|
+
if (input_id && seenClaudeChatInputIds.has(input_id, ctx.logger) && !claudeInflightByInputId.has(input_id)) {
|
|
2444
2475
|
// 再送 (ack 喪失想定)。同じターンを 2 回実行しないよう sendMessage を
|
|
2445
2476
|
// 呼び直さず ack のみ返す。
|
|
2446
2477
|
ctx.logger?.info?.({ stream_id, input_id }, "claude.input dedup (ack only)")
|
|
@@ -2491,10 +2522,10 @@ export function handleClaudeChatInput(msg, ctx) {
|
|
|
2491
2522
|
// 確認前キャンセルを可能にする)。
|
|
2492
2523
|
input_id,
|
|
2493
2524
|
})
|
|
2494
|
-
.then((result) => {
|
|
2525
|
+
.then(async (result) => {
|
|
2495
2526
|
if (!input_id) return
|
|
2496
2527
|
if (result?.ok) {
|
|
2497
|
-
seenClaudeChatInputIds.remember(input_id)
|
|
2528
|
+
await seenClaudeChatInputIds.remember(input_id, ctx.logger)
|
|
2498
2529
|
ctx.client.send({ type: "claude.input.ack", stream_id, input_id })
|
|
2499
2530
|
} else {
|
|
2500
2531
|
ctx.client.send({
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs"
|
|
2
|
+
import { mkdir, writeFile, rename, unlink } from "node:fs/promises"
|
|
3
|
+
import { randomUUID } from "node:crypto"
|
|
4
|
+
import path from "node:path"
|
|
5
|
+
import { paths } from "./config.mjs"
|
|
6
|
+
|
|
7
|
+
/** Claude の受理済み ID。初回だけ同期読み込みし、受付の同期的な順序を保つ。 */
|
|
8
|
+
export function makePersistentSeenInputIdRegistry(max = 1_000) {
|
|
9
|
+
const seen = new Set()
|
|
10
|
+
let storePath
|
|
11
|
+
let writeChain = Promise.resolve()
|
|
12
|
+
|
|
13
|
+
function trim() {
|
|
14
|
+
while (seen.size > max) seen.delete(seen.values().next().value)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function ensureLoaded(logger) {
|
|
18
|
+
if (storePath !== undefined) return
|
|
19
|
+
storePath = process.env.HUB_CLAUDE_SEEN_INPUT_IDS_PATH ||
|
|
20
|
+
path.join(paths.configDir, "claude-seen-input-ids.json")
|
|
21
|
+
try {
|
|
22
|
+
const ids = JSON.parse(readFileSync(storePath, "utf8"))
|
|
23
|
+
if (!Array.isArray(ids)) throw new Error("expected an input_id array")
|
|
24
|
+
if (ids.some((id) => typeof id !== "string")) {
|
|
25
|
+
logger?.warn?.("claude input_id store contains invalid entries")
|
|
26
|
+
}
|
|
27
|
+
for (const id of ids) if (typeof id === "string") seen.add(id)
|
|
28
|
+
trim()
|
|
29
|
+
} catch (err) {
|
|
30
|
+
if (err.code !== "ENOENT") {
|
|
31
|
+
logger?.warn?.({ err: err.message }, "claude input_id store load failed")
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
has(id, logger) {
|
|
38
|
+
ensureLoaded(logger)
|
|
39
|
+
return seen.has(id)
|
|
40
|
+
},
|
|
41
|
+
remember(id, logger) {
|
|
42
|
+
ensureLoaded(logger)
|
|
43
|
+
seen.add(id)
|
|
44
|
+
trim()
|
|
45
|
+
const snapshot = JSON.stringify([...seen])
|
|
46
|
+
// rename まで直列化し、古い snapshot が新しい記録を消さないようにする。
|
|
47
|
+
writeChain = writeChain.then(async () => {
|
|
48
|
+
const tmp = `${storePath}.${process.pid}.${randomUUID()}.tmp`
|
|
49
|
+
try {
|
|
50
|
+
await mkdir(path.dirname(storePath), { recursive: true, mode: 0o700 })
|
|
51
|
+
await writeFile(tmp, snapshot, { mode: 0o600, flag: "wx" })
|
|
52
|
+
await rename(tmp, storePath)
|
|
53
|
+
} catch (err) {
|
|
54
|
+
logger?.warn?.({ err: err.message }, "claude input_id store write failed")
|
|
55
|
+
} finally {
|
|
56
|
+
await unlink(tmp).catch(() => {})
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
return writeChain
|
|
60
|
+
},
|
|
61
|
+
/** 予約済みの保存がすべて終わるまで待つ(失敗は内部で warn 済みなので reject しない)。 */
|
|
62
|
+
flush() {
|
|
63
|
+
return writeChain
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
}
|