@cocorograph/hub-agent 0.8.52 → 0.8.54
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 +7 -1
- package/src/codex-stream-bridge.mjs +241 -19
- package/src/sibling-inject.mjs +88 -20
package/package.json
CHANGED
|
@@ -40,13 +40,19 @@ import { nextSessionSeq } from "./session-seq.mjs"
|
|
|
40
40
|
* この cwd の capability トークンを乗せる。ディスクには書かず、プロセス環境変数
|
|
41
41
|
* としてのみ渡す (src/sibling-token.mjs のコメント参照)。
|
|
42
42
|
* @param {string} cwd
|
|
43
|
-
* @returns {{COCKPIT_SIBLING_TOKEN: string, COCKPIT_SIBLING_CWD: string
|
|
43
|
+
* @returns {NodeJS.ProcessEnv & {COCKPIT_SIBLING_TOKEN: string, COCKPIT_SIBLING_CWD: string,
|
|
44
|
+
* COCKPIT_DELEGATE_EVENT_ACK: string}}
|
|
44
45
|
*/
|
|
45
46
|
function siblingEnvFor(cwd) {
|
|
46
47
|
return {
|
|
47
48
|
...process.env,
|
|
48
49
|
COCKPIT_SIBLING_TOKEN: getOrCreateSiblingToken(cwd),
|
|
49
50
|
COCKPIT_SIBLING_CWD: cwd,
|
|
51
|
+
// hub-agent が spawn した Claude 配下 producer だけが delegate-event の受理 ACK を
|
|
52
|
+
// 要求するための capability。能力は spawn 時点で固定されるため、hub-agent を旧版へ
|
|
53
|
+
// ロールバックしても既存 producer は ACK を要求し続け、timeout し得る。
|
|
54
|
+
// 旧 bundle はこの値を無視し、従来どおり fire-and-forget のまま動く。
|
|
55
|
+
COCKPIT_DELEGATE_EVENT_ACK: "1",
|
|
50
56
|
// ask-sibling スキル (cockpit_orch.py) が「自分は claude/codex どちらか」を
|
|
51
57
|
// 環境変数から確実に判定できるようにする (ヒューリスティック不要)。
|
|
52
58
|
COCKPIT_SIBLING_SELF_KIND: "claude",
|
|
@@ -1003,6 +1003,10 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
1003
1003
|
historyTruncated: false,
|
|
1004
1004
|
historyStart: 0,
|
|
1005
1005
|
historyTotal: 0,
|
|
1006
|
+
// cold resume が確定した parent cursor の基準件数と、それ以降の root live 追記数。
|
|
1007
|
+
// thread/read の短縮が「先頭 compaction」か「末尾反映待ち」かを区別する。
|
|
1008
|
+
historyColdBaseTotal: 0,
|
|
1009
|
+
historyLiveCursorAdditions: 0,
|
|
1006
1010
|
// reattach 表示履歴には child item も含むが cursor は parent item だけを数える。
|
|
1007
1011
|
// historyItems/historyCursorWeights は常に同長。weight は 1=parent / 0=child で、trim 時の
|
|
1008
1012
|
// start 前進量を決める。ready の start/total はこの cursor 専用で配列 index ではない。
|
|
@@ -1010,6 +1014,9 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
1010
1014
|
historyCursorWeights: [],
|
|
1011
1015
|
historySnapshotCache: new AsyncTtlCache({ ttlMs: this._historySnapshotCacheTtlMs }),
|
|
1012
1016
|
historySnapshotReleaseTimer: null,
|
|
1017
|
+
historySnapshotBuildSeq: 0,
|
|
1018
|
+
historySnapshotReturnedBuildSeq: 0,
|
|
1019
|
+
historySnapshotFreshOperations: new Set(),
|
|
1013
1020
|
historySnapshotComparable: false,
|
|
1014
1021
|
historyAttachInProgress: true,
|
|
1015
1022
|
historyItemArrivedDuringAttach: false,
|
|
@@ -1178,6 +1185,8 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
1178
1185
|
session.historyTruncated = historyTruncated
|
|
1179
1186
|
session.historyStart = flat.start
|
|
1180
1187
|
session.historyTotal = flat.total
|
|
1188
|
+
session.historyColdBaseTotal = flat.total
|
|
1189
|
+
session.historyLiveCursorAdditions = 0
|
|
1181
1190
|
session.historyCursorWeights = history.map(() => 1)
|
|
1182
1191
|
session.historyAttachInProgress = false
|
|
1183
1192
|
session.historySnapshotComparable = session.historyItemArrivedDuringAttach !== true
|
|
@@ -1248,7 +1257,7 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
1248
1257
|
} catch (err) {
|
|
1249
1258
|
this.sessions.delete(stream_id)
|
|
1250
1259
|
this._settleAllPendingApprovals(session, { respond: false })
|
|
1251
|
-
this.
|
|
1260
|
+
this._disposeSessionHistory(session)
|
|
1252
1261
|
client.stop()
|
|
1253
1262
|
settleReady(null) // reattach 待ちへ「失敗」を通知 (reject しない)
|
|
1254
1263
|
// 失敗も 1 行に落とす。**_classifyAttachFailure より前**に出す (分類は rollout の disk 走査を
|
|
@@ -1375,6 +1384,12 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
1375
1384
|
const cursorWeight = countsForCursor ? 1 : 0
|
|
1376
1385
|
session.historyCursorWeights.push(cursorWeight)
|
|
1377
1386
|
session.historyTotal = (Number.isInteger(session.historyTotal) ? session.historyTotal : 0) + cursorWeight
|
|
1387
|
+
if (cursorWeight === 1 && session.historyAttachInProgress !== true) {
|
|
1388
|
+
session.historyLiveCursorAdditions =
|
|
1389
|
+
(Number.isInteger(session.historyLiveCursorAdditions)
|
|
1390
|
+
? session.historyLiveCursorAdditions
|
|
1391
|
+
: 0) + 1
|
|
1392
|
+
}
|
|
1378
1393
|
// 古い page は同じ snapshot 座標内なら順序が壊れないため cache は clear しない。
|
|
1379
1394
|
// child 更新は parent thread/read snapshot にも無関係。TTL 後の再取得で自然に追随する。
|
|
1380
1395
|
session.historyBytes += estimateItemBytes(entry)
|
|
@@ -1432,14 +1447,170 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
1432
1447
|
session.historySnapshotCache?.clear?.()
|
|
1433
1448
|
}
|
|
1434
1449
|
|
|
1450
|
+
_abortFreshHistorySnapshot(session) {
|
|
1451
|
+
if (!session) return
|
|
1452
|
+
// clear() は進行中AsyncTtlCache buildを切り離して新世代buildを許すため、fresh operationも
|
|
1453
|
+
// 一時的に複数並走し得る。finally deleteとの競合を避け、snapshot配列を全件abort→stopする。
|
|
1454
|
+
const operations = [...(session.historySnapshotFreshOperations || [])]
|
|
1455
|
+
for (const operation of operations) {
|
|
1456
|
+
operation.abort?.()
|
|
1457
|
+
try {
|
|
1458
|
+
operation.stop?.()
|
|
1459
|
+
} catch (error) {
|
|
1460
|
+
this.logger?.warn?.(
|
|
1461
|
+
{
|
|
1462
|
+
error_code: codexHostCapabilityErrorCode(
|
|
1463
|
+
error,
|
|
1464
|
+
"CODEX_HISTORY_SNAPSHOT_STOP_FAILED",
|
|
1465
|
+
),
|
|
1466
|
+
},
|
|
1467
|
+
"codex history snapshot client stop failed",
|
|
1468
|
+
)
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
_disposeSessionHistory(session) {
|
|
1474
|
+
this._abortFreshHistorySnapshot(session)
|
|
1475
|
+
this._disposeHistorySnapshot(session)
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
async _resumeFullHistorySnapshot(
|
|
1479
|
+
session,
|
|
1480
|
+
advertisedTotal,
|
|
1481
|
+
requiredTotal,
|
|
1482
|
+
liveItems,
|
|
1483
|
+
coldBaseTotal,
|
|
1484
|
+
additionsAtRequest,
|
|
1485
|
+
) {
|
|
1486
|
+
const client = this._clientFactory({
|
|
1487
|
+
cwd: session.cwd,
|
|
1488
|
+
codexCmd: this.codexCmd,
|
|
1489
|
+
logger: this.logger,
|
|
1490
|
+
env: siblingEnvFor(session.cwd),
|
|
1491
|
+
})
|
|
1492
|
+
client.on("error", (error) => {
|
|
1493
|
+
this.logger?.warn?.(
|
|
1494
|
+
{
|
|
1495
|
+
error_code: codexHostCapabilityErrorCode(
|
|
1496
|
+
error,
|
|
1497
|
+
"CODEX_HISTORY_SNAPSHOT_CLIENT_ERROR",
|
|
1498
|
+
),
|
|
1499
|
+
},
|
|
1500
|
+
"codex history snapshot client error",
|
|
1501
|
+
)
|
|
1502
|
+
})
|
|
1503
|
+
const startedAt = Date.now()
|
|
1504
|
+
let stopped = false
|
|
1505
|
+
const stopClient = () => {
|
|
1506
|
+
if (stopped) return
|
|
1507
|
+
stopped = true
|
|
1508
|
+
client.stop()
|
|
1509
|
+
}
|
|
1510
|
+
let rejectAbort
|
|
1511
|
+
const aborted = new Promise((_, reject) => {
|
|
1512
|
+
rejectAbort = reject
|
|
1513
|
+
})
|
|
1514
|
+
const abort = () => {
|
|
1515
|
+
const error = new Error("stream_id is not attached")
|
|
1516
|
+
error.code = "CODEX_HISTORY_SNAPSHOT_FALLBACK_ABORTED"
|
|
1517
|
+
rejectAbort(error)
|
|
1518
|
+
}
|
|
1519
|
+
const operation = { client, abort, stop: stopClient }
|
|
1520
|
+
session.historySnapshotFreshOperations.add(operation)
|
|
1521
|
+
this.logger?.info?.(
|
|
1522
|
+
{
|
|
1523
|
+
thread_id: session.threadId,
|
|
1524
|
+
advertised_total: advertisedTotal,
|
|
1525
|
+
required_total: requiredTotal,
|
|
1526
|
+
live_items: liveItems,
|
|
1527
|
+
cold_base_total: coldBaseTotal,
|
|
1528
|
+
additions_at_request: additionsAtRequest,
|
|
1529
|
+
},
|
|
1530
|
+
"codex history snapshot fallback started",
|
|
1531
|
+
)
|
|
1532
|
+
try {
|
|
1533
|
+
const items = await Promise.race([
|
|
1534
|
+
(async () => {
|
|
1535
|
+
client.start()
|
|
1536
|
+
await client.request("initialize", {
|
|
1537
|
+
clientInfo: CLIENT_INFO,
|
|
1538
|
+
capabilities: {
|
|
1539
|
+
experimentalApi: true,
|
|
1540
|
+
mcpServerOpenaiFormElicitation: true,
|
|
1541
|
+
},
|
|
1542
|
+
})
|
|
1543
|
+
client.notify("initialized", {})
|
|
1544
|
+
const result = await client.request("thread/resume", {
|
|
1545
|
+
cwd: session.cwd,
|
|
1546
|
+
threadId: session.threadId,
|
|
1547
|
+
})
|
|
1548
|
+
if (result?.thread?.id !== session.threadId) {
|
|
1549
|
+
throw new Error("fresh thread/resume returned a different thread")
|
|
1550
|
+
}
|
|
1551
|
+
const resumedItems = flattenAllThreadItems(result.thread)
|
|
1552
|
+
if (resumedItems.length < requiredTotal) {
|
|
1553
|
+
throw new Error("fresh history snapshot is shorter than required prefix total")
|
|
1554
|
+
}
|
|
1555
|
+
return resumedItems
|
|
1556
|
+
})(),
|
|
1557
|
+
aborted,
|
|
1558
|
+
])
|
|
1559
|
+
this.logger?.info?.(
|
|
1560
|
+
{
|
|
1561
|
+
thread_id: session.threadId,
|
|
1562
|
+
advertised_total: advertisedTotal,
|
|
1563
|
+
required_total: requiredTotal,
|
|
1564
|
+
live_items: liveItems,
|
|
1565
|
+
cold_base_total: coldBaseTotal,
|
|
1566
|
+
additions_at_request: additionsAtRequest,
|
|
1567
|
+
elapsed_ms: Date.now() - startedAt,
|
|
1568
|
+
},
|
|
1569
|
+
"codex history snapshot fallback completed",
|
|
1570
|
+
)
|
|
1571
|
+
return items
|
|
1572
|
+
} catch (error) {
|
|
1573
|
+
const isAborted = error?.code === "CODEX_HISTORY_SNAPSHOT_FALLBACK_ABORTED"
|
|
1574
|
+
this.logger?.[isAborted ? "info" : "warn"]?.(
|
|
1575
|
+
{
|
|
1576
|
+
thread_id: session.threadId,
|
|
1577
|
+
advertised_total: advertisedTotal,
|
|
1578
|
+
required_total: requiredTotal,
|
|
1579
|
+
live_items: liveItems,
|
|
1580
|
+
cold_base_total: coldBaseTotal,
|
|
1581
|
+
additions_at_request: additionsAtRequest,
|
|
1582
|
+
elapsed_ms: Date.now() - startedAt,
|
|
1583
|
+
error_code: codexHostCapabilityErrorCode(
|
|
1584
|
+
error,
|
|
1585
|
+
"CODEX_HISTORY_SNAPSHOT_FALLBACK_FAILED",
|
|
1586
|
+
),
|
|
1587
|
+
},
|
|
1588
|
+
isAborted
|
|
1589
|
+
? "codex history snapshot fallback aborted"
|
|
1590
|
+
: "codex history snapshot fallback failed",
|
|
1591
|
+
)
|
|
1592
|
+
throw error
|
|
1593
|
+
} finally {
|
|
1594
|
+
stopClient()
|
|
1595
|
+
session.historySnapshotFreshOperations.delete(operation)
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1435
1599
|
async _historySnapshot(session) {
|
|
1436
1600
|
// この reader は既存の別責務の thread/read と統合しない。要求した root thread と応答の
|
|
1437
1601
|
// thread id が一致することを、この履歴取得経路自身で必ず検証する。
|
|
1438
1602
|
const cache = session.historySnapshotCache
|
|
1439
|
-
const
|
|
1603
|
+
const snapshot = await cache.get(async () => {
|
|
1604
|
+
const buildSeq = (Number.isInteger(session.historySnapshotBuildSeq)
|
|
1605
|
+
? session.historySnapshotBuildSeq
|
|
1606
|
+
: 0) + 1
|
|
1607
|
+
session.historySnapshotBuildSeq = buildSeq
|
|
1440
1608
|
// session破棄時の cache.clear() で進行中callerが新世代buildへ合流し直しても、停止済み
|
|
1441
1609
|
// clientへ2本目のthread/readを送らない。
|
|
1442
1610
|
if (session.dead) throw new Error("stream_id is not attached")
|
|
1611
|
+
const additionsAtRequest = Number.isInteger(session.historyLiveCursorAdditions)
|
|
1612
|
+
? session.historyLiveCursorAdditions
|
|
1613
|
+
: 0
|
|
1443
1614
|
const result = await session.client.request("thread/read", {
|
|
1444
1615
|
threadId: session.threadId,
|
|
1445
1616
|
includeTurns: true,
|
|
@@ -1447,11 +1618,22 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
1447
1618
|
if (result?.thread?.id !== session.threadId) {
|
|
1448
1619
|
throw new Error("thread/read returned a different thread")
|
|
1449
1620
|
}
|
|
1450
|
-
|
|
1621
|
+
if (session.dead) throw new Error("stream_id is not attached")
|
|
1622
|
+
let items = flattenAllThreadItems(result.thread)
|
|
1623
|
+
const coldBaseTotal = Number.isInteger(session.historyColdBaseTotal)
|
|
1624
|
+
? session.historyColdBaseTotal
|
|
1625
|
+
: 0
|
|
1626
|
+
const liveCursorAdditions = Number.isInteger(session.historyLiveCursorAdditions)
|
|
1627
|
+
? session.historyLiveCursorAdditions
|
|
1628
|
+
: 0
|
|
1629
|
+
const advertisedTotal = Math.max(
|
|
1630
|
+
Number.isInteger(session.historyTotal) ? session.historyTotal : 0,
|
|
1631
|
+
coldBaseTotal + liveCursorAdditions,
|
|
1632
|
+
)
|
|
1633
|
+
const knownPrefixAtRequest = coldBaseTotal + additionsAtRequest
|
|
1451
1634
|
if (
|
|
1452
1635
|
session.historySnapshotComparable === true &&
|
|
1453
|
-
|
|
1454
|
-
items.length !== session.historyTotal
|
|
1636
|
+
items.length !== advertisedTotal
|
|
1455
1637
|
) {
|
|
1456
1638
|
this.logger?.warn?.(
|
|
1457
1639
|
{
|
|
@@ -1462,14 +1644,50 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
1462
1644
|
"codex history snapshot item count differs from live parent cursor",
|
|
1463
1645
|
)
|
|
1464
1646
|
}
|
|
1647
|
+
let usedFresh = false
|
|
1648
|
+
// read開始時点で既知だったroot cursor prefixより短い場合だけcompactionと判定する。
|
|
1649
|
+
// read発行前に観測した加算はread応答に含まれる前提。await中のlive追加はknownPrefixへ
|
|
1650
|
+
// 含めず末尾lagとして許容し、この前提が破れた可能性はfallbackログで観測する。
|
|
1651
|
+
if (items.length < knownPrefixAtRequest) {
|
|
1652
|
+
const requiredTotal = coldBaseTotal > 0 ? coldBaseTotal : knownPrefixAtRequest
|
|
1653
|
+
items = await this._resumeFullHistorySnapshot(
|
|
1654
|
+
session,
|
|
1655
|
+
advertisedTotal,
|
|
1656
|
+
requiredTotal,
|
|
1657
|
+
items.length,
|
|
1658
|
+
coldBaseTotal,
|
|
1659
|
+
additionsAtRequest,
|
|
1660
|
+
)
|
|
1661
|
+
usedFresh = true
|
|
1662
|
+
// このbridgeはthread/rollback・thread/forkを呼ばず単調threadだけを扱うため、fresh
|
|
1663
|
+
// thread/resumeのitemsは先頭からのdurable prefix。fresh成功時だけbaseを単調に引上げ、
|
|
1664
|
+
// 並行live追加は差分として残す。
|
|
1665
|
+
session.historyColdBaseTotal = Math.max(
|
|
1666
|
+
Number.isInteger(session.historyColdBaseTotal) ? session.historyColdBaseTotal : 0,
|
|
1667
|
+
items.length,
|
|
1668
|
+
)
|
|
1669
|
+
const newBase = session.historyColdBaseTotal
|
|
1670
|
+
session.historyLiveCursorAdditions = Math.max(
|
|
1671
|
+
0,
|
|
1672
|
+
(Number.isInteger(session.historyTotal) ? session.historyTotal : 0) - newBase,
|
|
1673
|
+
)
|
|
1674
|
+
}
|
|
1675
|
+
if (session.dead) throw new Error("stream_id is not attached")
|
|
1465
1676
|
session.historyTotal = Math.max(
|
|
1466
1677
|
items.length,
|
|
1467
1678
|
Number.isInteger(session.historyTotal) ? session.historyTotal : 0,
|
|
1468
1679
|
)
|
|
1469
|
-
return items
|
|
1680
|
+
return { items, usedFresh, buildSeq }
|
|
1470
1681
|
})
|
|
1682
|
+
if (session.dead) throw new Error("stream_id is not attached")
|
|
1683
|
+
session.historySnapshotReturnedBuildSeq = Math.max(
|
|
1684
|
+
Number.isInteger(session.historySnapshotReturnedBuildSeq)
|
|
1685
|
+
? session.historySnapshotReturnedBuildSeq
|
|
1686
|
+
: 0,
|
|
1687
|
+
snapshot.buildSeq,
|
|
1688
|
+
)
|
|
1471
1689
|
this._scheduleHistorySnapshotRelease(session)
|
|
1472
|
-
return
|
|
1690
|
+
return snapshot
|
|
1473
1691
|
}
|
|
1474
1692
|
|
|
1475
1693
|
/**
|
|
@@ -1493,19 +1711,23 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
1493
1711
|
if (before > (Number.isInteger(session.historyTotal) ? session.historyTotal : 0)) {
|
|
1494
1712
|
throw new Error("before exceeds live history total")
|
|
1495
1713
|
}
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1714
|
+
const maxItems = this._historyLimits.maxItems
|
|
1715
|
+
const pageLimit = limit == null ? maxItems : Math.min(maxItems, limit)
|
|
1716
|
+
const seqAtCall = Number.isInteger(session.historySnapshotReturnedBuildSeq)
|
|
1717
|
+
? session.historySnapshotReturnedBuildSeq
|
|
1718
|
+
: 0
|
|
1719
|
+
let snapshot = await this._historySnapshot(session)
|
|
1720
|
+
// readyが広告した正当cursorへlive-only snapshotが追いついていない場合だけ、このrequest内で
|
|
1721
|
+
// 最大1回再buildする。fresh済みsnapshotは同一requestで2本目を起動せず即errorへ固定する。
|
|
1722
|
+
const usedFreshDuringCall = snapshot.usedFresh === true && snapshot.buildSeq > seqAtCall
|
|
1723
|
+
if (before > snapshot.items.length && !usedFreshDuringCall) {
|
|
1500
1724
|
this._disposeHistorySnapshot(session)
|
|
1501
|
-
|
|
1725
|
+
snapshot = await this._historySnapshot(session)
|
|
1502
1726
|
}
|
|
1503
|
-
if (before >
|
|
1727
|
+
if (before > snapshot.items.length) {
|
|
1504
1728
|
throw new Error("before exceeds history snapshot total")
|
|
1505
1729
|
}
|
|
1506
|
-
const
|
|
1507
|
-
const pageLimit = limit == null ? maxItems : Math.min(maxItems, limit)
|
|
1508
|
-
const page = pageThreadItems(all, {
|
|
1730
|
+
const page = pageThreadItems(snapshot.items, {
|
|
1509
1731
|
before,
|
|
1510
1732
|
maxItems: pageLimit,
|
|
1511
1733
|
maxBytes: this._historyLimits.maxBytes,
|
|
@@ -3174,7 +3396,7 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
3174
3396
|
if (session.dead) return
|
|
3175
3397
|
this._markRunningSubagentsKilled(session)
|
|
3176
3398
|
session.dead = true
|
|
3177
|
-
this.
|
|
3399
|
+
this._disposeSessionHistory(session)
|
|
3178
3400
|
// round3: app-server が死んだら barrier を解除し queue を reject する(promise 宙吊り防止)。
|
|
3179
3401
|
this._settleTurnActiveWaiter(session)
|
|
3180
3402
|
this._drainRejectQueue(session, { reason: "queue_dead" })
|
|
@@ -4209,7 +4431,7 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
4209
4431
|
if (session.dead) return
|
|
4210
4432
|
this._markRunningSubagentsKilled(session, now)
|
|
4211
4433
|
session.dead = true
|
|
4212
|
-
this.
|
|
4434
|
+
this._disposeSessionHistory(session)
|
|
4213
4435
|
// round3: reap した session の barrier を解除し queue を reject する(promise 宙吊り防止)。
|
|
4214
4436
|
this._settleTurnActiveWaiter(session)
|
|
4215
4437
|
this._drainRejectQueue(session, { reason: "queue_dead" })
|
|
@@ -4399,7 +4621,7 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
4399
4621
|
for (const session of this._liveSessions()) {
|
|
4400
4622
|
this._markRunningSubagentsKilled(session)
|
|
4401
4623
|
session.dead = true
|
|
4402
|
-
this.
|
|
4624
|
+
this._disposeSessionHistory(session)
|
|
4403
4625
|
// round3: shutdown で barrier を解除し queue 入力を reject(promise 宙吊り防止)。
|
|
4404
4626
|
this._settleTurnActiveWaiter(session)
|
|
4405
4627
|
this._drainRejectQueue(session, { reason: "queue_dead" })
|
package/src/sibling-inject.mjs
CHANGED
|
@@ -37,10 +37,13 @@
|
|
|
37
37
|
* - 安全な再起動: `{op:"restart"}` → `{ok:true, restart_pending:true}`。本文と token を持たず、
|
|
38
38
|
* 所有者限定の IPC directory/request file (0700/0600) だけを境界として扱う。
|
|
39
39
|
* 処理後は必ず削除し (成否問わず)、通常 IPC は呼び出し側が待ち受けられるよう
|
|
40
|
-
* `<request_id>.result.json` = `{ok, error?, delivered_to?, candidates?, live_sessions?, hint?,
|
|
40
|
+
* `<request_id>.result.json` = `{ok, error?, duplicate?, delivered_to?, candidates?, live_sessions?, hint?,
|
|
41
41
|
* candidates_nameable?}` を書く (`candidates_nameable` は曖昧系エラーのときのみ。CLI が
|
|
42
42
|
* 「候補を実行可能な `--to` として並べてよいか」を hint の文言に依存せず判定するための boolean)。
|
|
43
|
-
*
|
|
43
|
+
* 同一 request_id の再投入は二重処理防止で無視するため、意味上の再送は新しい transport ID を使う。
|
|
44
|
+
* `delegate-event` の `request_ack` は省略または literal true だけが正規入力。true のとき受理結果を
|
|
45
|
+
* 書き、不正値でも property があれば明示エラーを書く。未指定の旧 producer は fire-and-forget の
|
|
46
|
+
* ままなので、新旧 bundle / hub-agent のローリング互換を保つ。
|
|
44
47
|
*
|
|
45
48
|
* ⚠️ codex の `delivery_state` は 4 値ある (2026-08-05 に `queued` を追加):
|
|
46
49
|
* - `delivered` … userMessage echo まで確認済み (ok:true)
|
|
@@ -82,6 +85,7 @@ const SIBLING_SWEEP_DEBOUNCE_MS = 25
|
|
|
82
85
|
|
|
83
86
|
export const ACTIVE_DELEGATIONS_PER_SESSION_LIMIT = 100
|
|
84
87
|
export const DELEGATION_LEDGER_RETENTION_MS = 24 * 60 * 60 * 1000
|
|
88
|
+
export const SIBLING_RESULT_RETENTION_MS = 24 * 60 * 60 * 1000
|
|
85
89
|
const SIBLING_REQUEST_MAX_BYTES = 1024 * 1024
|
|
86
90
|
const DELEGATE_PROMPT_MAX_LENGTH = 64_000
|
|
87
91
|
const TERMINAL_DELEGATE_PROMPT_MAX_LENGTH = 2_000
|
|
@@ -491,8 +495,8 @@ export class SiblingInjectBridge {
|
|
|
491
495
|
}
|
|
492
496
|
}
|
|
493
497
|
|
|
494
|
-
_recordDelegateEvent(cwd, sessionId, event) {
|
|
495
|
-
this._pruneDelegations()
|
|
498
|
+
_recordDelegateEvent(cwd, sessionId, event, { prune = true } = {}) {
|
|
499
|
+
if (prune) this._pruneDelegations()
|
|
496
500
|
const sessionKey = this._delegationSessionKey(cwd, sessionId)
|
|
497
501
|
if (event.phase !== "started") {
|
|
498
502
|
const activeEntry = this._activeDelegations.get(sessionKey)?.get(event.delegation_id)
|
|
@@ -542,11 +546,40 @@ export class SiblingInjectBridge {
|
|
|
542
546
|
}
|
|
543
547
|
}
|
|
544
548
|
|
|
545
|
-
|
|
549
|
+
_delegateEventAcceptance(cwd, sessionId, event) {
|
|
546
550
|
this._pruneDelegations()
|
|
547
|
-
if (event.phase !== "started") return true
|
|
548
551
|
const sessionKey = this._delegationSessionKey(cwd, sessionId)
|
|
549
|
-
|
|
552
|
+
const terminal = this._terminalDelegations.get(sessionKey)?.get(event.delegation_id)
|
|
553
|
+
if (event.phase === "started") {
|
|
554
|
+
if (terminal) return "stale"
|
|
555
|
+
return this._activeDelegations.get(sessionKey)?.has(event.delegation_id)
|
|
556
|
+
? "duplicate"
|
|
557
|
+
: "accept"
|
|
558
|
+
}
|
|
559
|
+
if (!terminal) return "accept"
|
|
560
|
+
// terminal 同士は後から届いた実イベントを最新終端として上書きできる。
|
|
561
|
+
// 例: replay が PID 消失から stopped を合成した後に producer の completed が届く。
|
|
562
|
+
return terminal.terminalEvent?.phase === event.phase ? "duplicate" : "accept"
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
_liveClaudeSession(cwd, sessionId) {
|
|
566
|
+
const matches = describeLiveSessionsUnion(this._bridges(), {
|
|
567
|
+
cwd,
|
|
568
|
+
cliKind: "claude",
|
|
569
|
+
}).filter((session) => session.session_id === sessionId)
|
|
570
|
+
if (matches.length === 1) return { status: "delivered", session: matches[0] }
|
|
571
|
+
return {
|
|
572
|
+
status: matches.length > 1 ? "ambiguous" : "not_found",
|
|
573
|
+
...(matches.length ? { candidates: matches } : {}),
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
_delegateTargetFailure(targetResult) {
|
|
578
|
+
return {
|
|
579
|
+
ok: false,
|
|
580
|
+
error: AMBIGUOUS_TARGET_ERROR,
|
|
581
|
+
...(targetResult?.candidates ? { candidates: targetResult.candidates } : {}),
|
|
582
|
+
}
|
|
550
583
|
}
|
|
551
584
|
|
|
552
585
|
async _delegateProcessAlive(event) {
|
|
@@ -699,7 +732,26 @@ export class SiblingInjectBridge {
|
|
|
699
732
|
return
|
|
700
733
|
}
|
|
701
734
|
for (const n of names) {
|
|
702
|
-
if (n.endsWith(".
|
|
735
|
+
if (n.endsWith(".result.json")) {
|
|
736
|
+
const fp = path.join(this.dir, n)
|
|
737
|
+
try {
|
|
738
|
+
const info = await lstat(fp)
|
|
739
|
+
const uid = process.getuid?.()
|
|
740
|
+
if (
|
|
741
|
+
!info.isSymbolicLink() &&
|
|
742
|
+
info.isFile() &&
|
|
743
|
+
uid != null &&
|
|
744
|
+
info.uid === uid &&
|
|
745
|
+
// 壁時計が前へ step すると保持期間が短くなるが、result は再配送の正本ではなく
|
|
746
|
+
// 呼び出し側が回収する一時 ACK なので、24時間経過扱いでの掃除を許容する。
|
|
747
|
+
info.mtimeMs < this._now() - SIBLING_RESULT_RETENTION_MS
|
|
748
|
+
) {
|
|
749
|
+
await unlink(fp)
|
|
750
|
+
}
|
|
751
|
+
} catch {
|
|
752
|
+
/* 同時に呼び出し側が回収済みなら no-op */
|
|
753
|
+
}
|
|
754
|
+
} else if (n.endsWith(".json")) {
|
|
703
755
|
await this._ingest(n, { startup }).catch((err) => {
|
|
704
756
|
this.logger?.warn({ err: err?.message, filename: n }, "sibling-inject sweep failed")
|
|
705
757
|
})
|
|
@@ -709,7 +761,8 @@ export class SiblingInjectBridge {
|
|
|
709
761
|
|
|
710
762
|
/**
|
|
711
763
|
* 要求ファイル 1 件を読み、トークン検証後に対象 cwd のセッションへ注入する。
|
|
712
|
-
* 成否によらず要求ファイルは削除する。delegate-event
|
|
764
|
+
* 成否によらず要求ファイルは削除する。delegate-event は request_ack:true または
|
|
765
|
+
* malformed な request_ack property がある場合だけ結果ファイルも書く。
|
|
713
766
|
* @param {string} filename `<request_id>.json`
|
|
714
767
|
*/
|
|
715
768
|
async _ingest(filename, { startup = false } = {}) {
|
|
@@ -729,8 +782,8 @@ export class SiblingInjectBridge {
|
|
|
729
782
|
// ⚠️ **結果ファイルを必ず書く**。ここで書かずに戻ると、結果をポーリングしている
|
|
730
783
|
// 呼び出し側 (cockpit_orch.py) には「タイムアウト」としか見えない。タイムアウトは
|
|
731
784
|
// 「不達とは限らない」と扱う契約なので、拒否した事実が呼び出し側で不達に化ける
|
|
732
|
-
// (=黙って捨てたのと同じになる)
|
|
733
|
-
//
|
|
785
|
+
// (=黙って捨てたのと同じになる)。ここでは本文を parse できず op / request_ack を
|
|
786
|
+
// 判別できないため無条件に書く。旧 producer 向けの余分な結果は stale 掃除の対象。
|
|
734
787
|
await this._writeResult(request_id, {
|
|
735
788
|
ok: false,
|
|
736
789
|
error: "request_too_large",
|
|
@@ -778,6 +831,7 @@ export class SiblingInjectBridge {
|
|
|
778
831
|
timestamp,
|
|
779
832
|
pid,
|
|
780
833
|
pid_started_at,
|
|
834
|
+
request_ack,
|
|
781
835
|
} = body || {}
|
|
782
836
|
// 連携カード種別 (Phase 2, 2026-07-23): 注入を受け手画面で専用カード表示させるためのタグ。
|
|
783
837
|
// 既知値 ('peer'|'orchestrator') のみ通し、未知値/未指定は undefined に落とす (未知の source を
|
|
@@ -787,6 +841,7 @@ export class SiblingInjectBridge {
|
|
|
787
841
|
const isList = op === "list"
|
|
788
842
|
const isExplicit = op === "inject"
|
|
789
843
|
const isDelegateEvent = op === "delegate-event"
|
|
844
|
+
const hasRequestAck = Object.prototype.hasOwnProperty.call(body || {}, "request_ack")
|
|
790
845
|
const isRestart = op === "restart"
|
|
791
846
|
let result
|
|
792
847
|
const badKind = target_cli_kind !== "claude" && target_cli_kind !== "codex"
|
|
@@ -803,6 +858,7 @@ export class SiblingInjectBridge {
|
|
|
803
858
|
(prompt !== undefined && typeof prompt !== "string") ||
|
|
804
859
|
(pid !== undefined && (!Number.isInteger(pid) || pid <= 0)) ||
|
|
805
860
|
(pid_started_at !== undefined && typeof pid_started_at !== "string") ||
|
|
861
|
+
(request_ack !== undefined && request_ack !== true) ||
|
|
806
862
|
(timestamp !== undefined &&
|
|
807
863
|
(typeof timestamp !== "string" || Number.isNaN(Date.parse(timestamp))))
|
|
808
864
|
const badBody = isDelegateEvent
|
|
@@ -892,21 +948,26 @@ export class SiblingInjectBridge {
|
|
|
892
948
|
}
|
|
893
949
|
// 墓標確認から配送成功時の台帳反映まで await を挟まない同期区間に閉じる。
|
|
894
950
|
// 配送失敗した started は台帳へ残さず、終端後に遅着した started は配送前に拒否する。
|
|
895
|
-
|
|
951
|
+
const acceptance = this._delegateEventAcceptance(cwd, target_session_id, event)
|
|
952
|
+
if (acceptance === "duplicate") {
|
|
953
|
+
// ACK は「過去に受理した」だけでなく、対象 stream が現在も live であることも示す。
|
|
954
|
+
// 台帳だけで成功を返すと、宛先消失後の retry を受理済みと誤認させる。
|
|
955
|
+
const live = this._liveClaudeSession(cwd, target_session_id)
|
|
956
|
+
result = live.status === "delivered"
|
|
957
|
+
? { ok: true, duplicate: true }
|
|
958
|
+
: this._delegateTargetFailure(live)
|
|
959
|
+
} else if (acceptance === "stale") {
|
|
896
960
|
result = { ok: false, error: "stale_delegate_event" }
|
|
897
961
|
} else {
|
|
898
962
|
const r = this.claudeBridge?.emitToStream
|
|
899
963
|
? this.claudeBridge.emitToStream(cwd, target_session_id, event)
|
|
900
964
|
: { status: "not_found" }
|
|
901
965
|
if (r.status === "delivered") {
|
|
902
|
-
|
|
966
|
+
// acceptance 直前に prune 済み。同じ同期区間で二重 prune しない。
|
|
967
|
+
this._recordDelegateEvent(cwd, target_session_id, event, { prune: false })
|
|
903
968
|
result = { ok: true, delivered_to: r.session }
|
|
904
969
|
} else {
|
|
905
|
-
result =
|
|
906
|
-
ok: false,
|
|
907
|
-
error: AMBIGUOUS_TARGET_ERROR,
|
|
908
|
-
...(r.candidates ? { candidates: r.candidates } : {}),
|
|
909
|
-
}
|
|
970
|
+
result = this._delegateTargetFailure(r)
|
|
910
971
|
}
|
|
911
972
|
}
|
|
912
973
|
}
|
|
@@ -1068,6 +1129,7 @@ export class SiblingInjectBridge {
|
|
|
1068
1129
|
delivery_state: result.delivery_state,
|
|
1069
1130
|
durable_state: result.durable_state,
|
|
1070
1131
|
reason: result.reason,
|
|
1132
|
+
duplicate: result.duplicate,
|
|
1071
1133
|
}
|
|
1072
1134
|
: {
|
|
1073
1135
|
error: result.error,
|
|
@@ -1077,7 +1139,9 @@ export class SiblingInjectBridge {
|
|
|
1077
1139
|
}),
|
|
1078
1140
|
},
|
|
1079
1141
|
result.ok
|
|
1080
|
-
? result.
|
|
1142
|
+
? result.duplicate
|
|
1143
|
+
? "sibling-inject duplicate acknowledged"
|
|
1144
|
+
: result.delivery_state === "delivered"
|
|
1081
1145
|
? "sibling-inject delivered"
|
|
1082
1146
|
: result.delivery_state === "queued"
|
|
1083
1147
|
? "sibling-inject queued"
|
|
@@ -1085,7 +1149,11 @@ export class SiblingInjectBridge {
|
|
|
1085
1149
|
: "sibling-inject failed",
|
|
1086
1150
|
)
|
|
1087
1151
|
// startup sweep 由来の restart には待受者がいない。孤児 result を残さない。
|
|
1088
|
-
if (!isDelegateEvent && !(isRestart && startup))
|
|
1152
|
+
if ((!isDelegateEvent || hasRequestAck) && !(isRestart && startup)) {
|
|
1153
|
+
// delegate ACK は「対象 Claude stream の内部 event 経路へ受理」までの保証。
|
|
1154
|
+
// ブラウザ側がイベントを登録・描画したことまでは保証しない。
|
|
1155
|
+
await this._writeResult(request_id, result)
|
|
1156
|
+
}
|
|
1089
1157
|
// 要求ファイルは処理後に必ず削除する (成否問わず、再処理させないため)。
|
|
1090
1158
|
try {
|
|
1091
1159
|
await unlink(fp)
|