@cocorograph/hub-agent 0.8.52 → 0.8.53

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cocorograph/hub-agent",
3
- "version": "0.8.52",
3
+ "version": "0.8.53",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの Claude / Codex チャットと tmux セッションを中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -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._disposeHistorySnapshot(session)
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 items = await cache.get(async () => {
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
- const items = flattenAllThreadItems(result.thread)
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
- Number.isInteger(session.historyTotal) &&
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 items
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
- let all = await this._historySnapshot(session)
1497
- // readyが広告した正当cursorへTTL snapshotが追いついていない場合だけ、このrequest内で最大1回
1498
- // 強制再buildする。再帰/loopは使わず、なお短ければerrorへ固定する。
1499
- if (before > all.length) {
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
- all = await this._historySnapshot(session)
1725
+ snapshot = await this._historySnapshot(session)
1502
1726
  }
1503
- if (before > all.length) {
1727
+ if (before > snapshot.items.length) {
1504
1728
  throw new Error("before exceeds history snapshot total")
1505
1729
  }
1506
- const maxItems = this._historyLimits.maxItems
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._disposeHistorySnapshot(session)
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._disposeHistorySnapshot(session)
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._disposeHistorySnapshot(session)
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" })