@cocorograph/hub-agent 0.8.40 → 0.8.42
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/codex-stream-bridge.mjs +210 -10
- package/src/main.mjs +28 -3
package/package.json
CHANGED
|
@@ -56,6 +56,11 @@ import { readPositiveIntEnv, sanitizePositiveInt } from "./positive-int-env.mjs"
|
|
|
56
56
|
import { nextSessionSeq } from "./session-seq.mjs"
|
|
57
57
|
import { codexRolloutPresence, listCodexThreadsFromDisk } from "./usage.mjs"
|
|
58
58
|
|
|
59
|
+
function parseTurnMismatch(reason) {
|
|
60
|
+
const mismatch = /^expected active turn id (\S+) but found (\S+)$/.exec(reason)
|
|
61
|
+
return mismatch ? { expected: mismatch[1], found: mismatch[2] } : null
|
|
62
|
+
}
|
|
63
|
+
|
|
59
64
|
/**
|
|
60
65
|
* sibling-inject (2026-07-04) 用: spawn する codex app-server プロセス自身の
|
|
61
66
|
* 環境変数に、この cwd の capability トークンを乗せる。ディスクには書かず、
|
|
@@ -765,6 +770,8 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
765
770
|
/** 組み込み collaboration subagent の表示用台帳。検索キーは agentThreadId、
|
|
766
771
|
* 外部へ出す安定した委譲 ID は subAgentActivity の lifecycle ID を保持する。 */
|
|
767
772
|
subagents: new Map(),
|
|
773
|
+
/** 停止対象の因果関係と実行中 turn を保持する、表示台帳とは独立した台帳。 */
|
|
774
|
+
subagentInterruptRegistry: new Map(),
|
|
768
775
|
/** dynamicToolCall(spawn_agent) の item.id -> 表示メタデータ。 */
|
|
769
776
|
subagentSpawnMetadata: new Map(),
|
|
770
777
|
/** live subAgentActivity の spawn metadata miss を child thread/read で補完する
|
|
@@ -1754,9 +1761,22 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
1754
1761
|
session,
|
|
1755
1762
|
item,
|
|
1756
1763
|
atMs = Date.now(),
|
|
1757
|
-
{ emit = true, sourceThreadId = session?.threadId } = {},
|
|
1764
|
+
{ emit = true, sourceThreadId = session?.threadId, sourceTurnId = null } = {},
|
|
1758
1765
|
) {
|
|
1759
1766
|
if (!item || typeof item !== "object") return
|
|
1767
|
+
if (item.type === "subAgentActivity") {
|
|
1768
|
+
const childThreadId = item.agentThreadId
|
|
1769
|
+
if (typeof childThreadId === "string" && childThreadId && childThreadId !== session?.threadId) {
|
|
1770
|
+
const interruptFields = {}
|
|
1771
|
+
if (typeof sourceThreadId === "string" && sourceThreadId) {
|
|
1772
|
+
interruptFields.parentThreadId = sourceThreadId
|
|
1773
|
+
}
|
|
1774
|
+
if (typeof sourceTurnId === "string" && sourceTurnId) {
|
|
1775
|
+
interruptFields.ownerTurnId = sourceTurnId
|
|
1776
|
+
}
|
|
1777
|
+
this._touchSubagentInterruptRecord(session, childThreadId, interruptFields)
|
|
1778
|
+
}
|
|
1779
|
+
}
|
|
1760
1780
|
// root thread の collaboration item だけが root subagent 台帳の正本。子 thread 内の
|
|
1761
1781
|
// spawn/subAgentActivity(孫)を root の lifecycle/snapshot へ昇格しない。
|
|
1762
1782
|
if (
|
|
@@ -1818,8 +1838,6 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
1818
1838
|
_observeSubagentTurn(session, msg) {
|
|
1819
1839
|
const threadId = msg?.params?.threadId
|
|
1820
1840
|
if (typeof threadId !== "string" || threadId === session.threadId) return false
|
|
1821
|
-
const record = session.subagents.get(threadId)
|
|
1822
|
-
if (!record) return true
|
|
1823
1841
|
const turn = msg.params?.turn
|
|
1824
1842
|
const atMs =
|
|
1825
1843
|
typeof msg.params?.completedAtMs === "number"
|
|
@@ -1831,6 +1849,16 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
1831
1849
|
: typeof turn?.startedAt === "number"
|
|
1832
1850
|
? Math.round(turn.startedAt * 1000)
|
|
1833
1851
|
: Date.now()
|
|
1852
|
+
const interruptRecord = this._touchSubagentInterruptRecord(session, threadId)
|
|
1853
|
+
const observedTurnId = typeof turn?.id === "string" ? turn.id : null
|
|
1854
|
+
if (msg.method === "turn/started" && observedTurnId) {
|
|
1855
|
+
interruptRecord.turnId = observedTurnId
|
|
1856
|
+
interruptRecord.startedAtMs = atMs
|
|
1857
|
+
} else if (msg.method === "turn/completed" && interruptRecord.turnId === observedTurnId) {
|
|
1858
|
+
interruptRecord.turnId = null
|
|
1859
|
+
}
|
|
1860
|
+
const record = session.subagents.get(threadId)
|
|
1861
|
+
if (!record) return true
|
|
1834
1862
|
if (msg.method === "turn/started") {
|
|
1835
1863
|
this._setSubagentPhase(session, threadId, "started", {
|
|
1836
1864
|
agentPath: record.agentPath,
|
|
@@ -1848,6 +1876,127 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
1848
1876
|
return true
|
|
1849
1877
|
}
|
|
1850
1878
|
|
|
1879
|
+
_touchSubagentInterruptRecord(session, threadId, fields = {}) {
|
|
1880
|
+
const now = Date.now()
|
|
1881
|
+
let record = session.subagentInterruptRegistry.get(threadId)
|
|
1882
|
+
if (!record) {
|
|
1883
|
+
record = {
|
|
1884
|
+
threadId,
|
|
1885
|
+
turnId: null,
|
|
1886
|
+
parentThreadId: null,
|
|
1887
|
+
ownerTurnId: null,
|
|
1888
|
+
startedAtMs: null,
|
|
1889
|
+
lastSeenAtMs: now,
|
|
1890
|
+
}
|
|
1891
|
+
session.subagentInterruptRegistry.set(threadId, record)
|
|
1892
|
+
}
|
|
1893
|
+
const { parentThreadId, ownerTurnId, ...mutableFields } = fields
|
|
1894
|
+
Object.assign(record, mutableFields, { lastSeenAtMs: now })
|
|
1895
|
+
if (!record.parentThreadId && typeof parentThreadId === "string" && parentThreadId) {
|
|
1896
|
+
record.parentThreadId = parentThreadId
|
|
1897
|
+
}
|
|
1898
|
+
if (!record.ownerTurnId && typeof ownerTurnId === "string" && ownerTurnId) {
|
|
1899
|
+
record.ownerTurnId = ownerTurnId
|
|
1900
|
+
}
|
|
1901
|
+
const expiredBefore = now - 4 * 60 * 60 * 1000
|
|
1902
|
+
for (const [id, candidate] of session.subagentInterruptRegistry) {
|
|
1903
|
+
if (candidate.turnId === null && candidate.lastSeenAtMs < expiredBefore) {
|
|
1904
|
+
this._evictSubagentInterruptRecord(session, id, candidate)
|
|
1905
|
+
}
|
|
1906
|
+
}
|
|
1907
|
+
while (session.subagentInterruptRegistry.size > 256) {
|
|
1908
|
+
const oldest = [...session.subagentInterruptRegistry.entries()]
|
|
1909
|
+
.filter(([, candidate]) => candidate.turnId === null)
|
|
1910
|
+
.sort((a, b) => a[1].lastSeenAtMs - b[1].lastSeenAtMs)[0]
|
|
1911
|
+
if (!oldest) {
|
|
1912
|
+
this.logger?.warn?.(
|
|
1913
|
+
{ size: session.subagentInterruptRegistry.size, limit: 256 },
|
|
1914
|
+
"codex subagent interrupt registry exceeds limit with all records active",
|
|
1915
|
+
)
|
|
1916
|
+
break
|
|
1917
|
+
}
|
|
1918
|
+
this._evictSubagentInterruptRecord(session, oldest[0], oldest[1])
|
|
1919
|
+
}
|
|
1920
|
+
return record
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
_evictSubagentInterruptRecord(session, threadId, record) {
|
|
1924
|
+
if (record.turnId) {
|
|
1925
|
+
this.logger?.warn?.({ thread_id: threadId, turn_id: record.turnId }, "evicting active codex subagent interrupt record")
|
|
1926
|
+
}
|
|
1927
|
+
session.subagentInterruptRegistry.delete(threadId)
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
_subagentOwnership(session, record) {
|
|
1931
|
+
const visited = new Set()
|
|
1932
|
+
let current = record
|
|
1933
|
+
let hasBackgroundAncestor = false
|
|
1934
|
+
while (current) {
|
|
1935
|
+
if (visited.has(current.threadId)) return { ownerRootTurnId: null, hasBackgroundAncestor }
|
|
1936
|
+
visited.add(current.threadId)
|
|
1937
|
+
if (session.subagents.get(current.threadId)?.background === true) {
|
|
1938
|
+
hasBackgroundAncestor = true
|
|
1939
|
+
}
|
|
1940
|
+
if (current.parentThreadId === session.threadId) {
|
|
1941
|
+
return { ownerRootTurnId: current.ownerTurnId || null, hasBackgroundAncestor }
|
|
1942
|
+
}
|
|
1943
|
+
current = session.subagentInterruptRegistry.get(current.parentThreadId)
|
|
1944
|
+
}
|
|
1945
|
+
return { ownerRootTurnId: null, hasBackgroundAncestor }
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
async _interruptSubagent(session, record, targetRootTurnId) {
|
|
1949
|
+
const displayed = session.subagents.get(record.threadId)
|
|
1950
|
+
const backgroundKnown = displayed ? typeof displayed.background === "boolean" : false
|
|
1951
|
+
const background = backgroundKnown ? displayed.background : null
|
|
1952
|
+
try {
|
|
1953
|
+
await session.client.request("turn/interrupt", { threadId: record.threadId, turnId: record.turnId })
|
|
1954
|
+
return { threadId: record.threadId, turnId: record.turnId, outcome: "interrupted", background, backgroundKnown }
|
|
1955
|
+
} catch (err) {
|
|
1956
|
+
const reason = err?.message || String(err)
|
|
1957
|
+
const mismatch = parseTurnMismatch(reason)
|
|
1958
|
+
const latestRecord = session.subagentInterruptRegistry.get(record.threadId)
|
|
1959
|
+
const latestOwnership = latestRecord ? this._subagentOwnership(session, latestRecord) : null
|
|
1960
|
+
if (
|
|
1961
|
+
mismatch &&
|
|
1962
|
+
latestRecord?.turnId === mismatch.found &&
|
|
1963
|
+
latestOwnership?.ownerRootTurnId === targetRootTurnId &&
|
|
1964
|
+
!latestOwnership.hasBackgroundAncestor
|
|
1965
|
+
) {
|
|
1966
|
+
try {
|
|
1967
|
+
await session.client.request("turn/interrupt", {
|
|
1968
|
+
threadId: latestRecord.threadId,
|
|
1969
|
+
turnId: latestRecord.turnId,
|
|
1970
|
+
})
|
|
1971
|
+
return {
|
|
1972
|
+
threadId: latestRecord.threadId,
|
|
1973
|
+
turnId: latestRecord.turnId,
|
|
1974
|
+
outcome: "interrupted",
|
|
1975
|
+
background,
|
|
1976
|
+
backgroundKnown,
|
|
1977
|
+
}
|
|
1978
|
+
} catch (retryErr) {
|
|
1979
|
+
return {
|
|
1980
|
+
threadId: latestRecord.threadId,
|
|
1981
|
+
turnId: latestRecord.turnId,
|
|
1982
|
+
outcome: "failed",
|
|
1983
|
+
reason: retryErr?.message || String(retryErr),
|
|
1984
|
+
background,
|
|
1985
|
+
backgroundKnown,
|
|
1986
|
+
}
|
|
1987
|
+
}
|
|
1988
|
+
}
|
|
1989
|
+
return {
|
|
1990
|
+
threadId: record.threadId,
|
|
1991
|
+
turnId: record.turnId,
|
|
1992
|
+
outcome: "failed",
|
|
1993
|
+
reason,
|
|
1994
|
+
background,
|
|
1995
|
+
backgroundKnown,
|
|
1996
|
+
}
|
|
1997
|
+
}
|
|
1998
|
+
}
|
|
1999
|
+
|
|
1851
2000
|
_subagentSnapshots(session, history = session?.historyItems) {
|
|
1852
2001
|
const items = Array.isArray(history) ? history : []
|
|
1853
2002
|
const startedIndexByThread = new Map()
|
|
@@ -2058,7 +2207,7 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
2058
2207
|
session,
|
|
2059
2208
|
effectiveParams?.item,
|
|
2060
2209
|
effectiveParams?.completedAtMs || Date.now(),
|
|
2061
|
-
{ sourceThreadId: eventThreadId },
|
|
2210
|
+
{ sourceThreadId: eventThreadId, sourceTurnId: msg.params?.turnId },
|
|
2062
2211
|
)
|
|
2063
2212
|
}
|
|
2064
2213
|
const childTurnNotification =
|
|
@@ -2463,15 +2612,66 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
2463
2612
|
if (!session.dead) this._emitQueueState(session)
|
|
2464
2613
|
}
|
|
2465
2614
|
|
|
2466
|
-
/**
|
|
2615
|
+
/**
|
|
2616
|
+
* @returns {Promise<
|
|
2617
|
+
* | { outcome: "interrupted", turnId: string, corrected: boolean }
|
|
2618
|
+
* | { outcome: "no_active_turn" }
|
|
2619
|
+
* | { outcome: "failed", reason: string }
|
|
2620
|
+
* >}
|
|
2621
|
+
*/
|
|
2467
2622
|
async interrupt({ stream_id }) {
|
|
2468
2623
|
const session = this._require(stream_id)
|
|
2469
2624
|
this._touch(session, stream_id)
|
|
2470
|
-
if (!session.activeTurnId) return
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2625
|
+
if (!session.activeTurnId) return { outcome: "no_active_turn", children: [] }
|
|
2626
|
+
|
|
2627
|
+
const turnId = session.activeTurnId
|
|
2628
|
+
let attemptedRootTurnId = turnId
|
|
2629
|
+
let result
|
|
2630
|
+
try {
|
|
2631
|
+
await session.client.request("turn/interrupt", {
|
|
2632
|
+
threadId: session.threadId,
|
|
2633
|
+
turnId,
|
|
2634
|
+
})
|
|
2635
|
+
result = { outcome: "interrupted", turnId, corrected: false }
|
|
2636
|
+
} catch (err) {
|
|
2637
|
+
const reason = err?.message || String(err)
|
|
2638
|
+
const mismatch = parseTurnMismatch(reason)
|
|
2639
|
+
if (!mismatch) result = { outcome: "failed", reason }
|
|
2640
|
+
|
|
2641
|
+
if (mismatch) {
|
|
2642
|
+
const correctedTurnId = mismatch.found
|
|
2643
|
+
// 是正は再送前に行い、再送中の corrected turn の完了通知を正しく照合できるようにする。
|
|
2644
|
+
// 初回リクエスト中に状態が進んでいた場合は、その新しい状態を上書きしない。
|
|
2645
|
+
if (session.activeTurnId === turnId) {
|
|
2646
|
+
session.activeTurnId = correctedTurnId
|
|
2647
|
+
}
|
|
2648
|
+
try {
|
|
2649
|
+
attemptedRootTurnId = correctedTurnId
|
|
2650
|
+
await session.client.request("turn/interrupt", {
|
|
2651
|
+
threadId: session.threadId,
|
|
2652
|
+
turnId: correctedTurnId,
|
|
2653
|
+
})
|
|
2654
|
+
result = { outcome: "interrupted", turnId: correctedTurnId, corrected: true }
|
|
2655
|
+
} catch (retryErr) {
|
|
2656
|
+
const retryReason = retryErr?.message || String(retryErr)
|
|
2657
|
+
const retryMismatch = parseTurnMismatch(retryReason)
|
|
2658
|
+
if (retryMismatch && session.activeTurnId === correctedTurnId) {
|
|
2659
|
+
session.activeTurnId = retryMismatch.found
|
|
2660
|
+
}
|
|
2661
|
+
result = { outcome: "failed", reason: retryReason }
|
|
2662
|
+
}
|
|
2663
|
+
}
|
|
2664
|
+
}
|
|
2665
|
+
const children = []
|
|
2666
|
+
const targetRootTurnId = attemptedRootTurnId
|
|
2667
|
+
const interruptSnapshot = [...session.subagentInterruptRegistry.values()].map((record) => ({ ...record }))
|
|
2668
|
+
for (const record of interruptSnapshot) {
|
|
2669
|
+
if (!record.turnId) continue
|
|
2670
|
+
const ownership = this._subagentOwnership(session, record)
|
|
2671
|
+
if (ownership.ownerRootTurnId !== targetRootTurnId || ownership.hasBackgroundAncestor) continue
|
|
2672
|
+
children.push(await this._interruptSubagent(session, { ...record }, targetRootTurnId))
|
|
2673
|
+
}
|
|
2674
|
+
return { ...result, children }
|
|
2475
2675
|
}
|
|
2476
2676
|
|
|
2477
2677
|
/** サーバー発の承認/入力要求への応答。result の shape は method ごとに呼び出し側が組み立てる。 */
|
package/src/main.mjs
CHANGED
|
@@ -3194,6 +3194,30 @@ export function handleCodexChatInput(msg, ctx) {
|
|
|
3194
3194
|
return p
|
|
3195
3195
|
}
|
|
3196
3196
|
|
|
3197
|
+
export async function handleCodexInterrupt(msg, ctx) {
|
|
3198
|
+
let result
|
|
3199
|
+
if (!ctx.codexBridge) {
|
|
3200
|
+
result = { outcome: "failed", reason: "bridge_unavailable", children: [] }
|
|
3201
|
+
} else {
|
|
3202
|
+
try {
|
|
3203
|
+
result = await ctx.codexBridge.interrupt({ stream_id: msg.stream_id })
|
|
3204
|
+
if (result.outcome === "failed") {
|
|
3205
|
+
ctx.logger?.warn?.({ err: result.reason }, "codex.interrupt failed")
|
|
3206
|
+
}
|
|
3207
|
+
} catch (err) {
|
|
3208
|
+
const reason = err?.message || String(err)
|
|
3209
|
+
ctx.logger?.warn?.({ err: reason }, "codex.interrupt failed")
|
|
3210
|
+
result = { outcome: "failed", reason, children: [] }
|
|
3211
|
+
}
|
|
3212
|
+
}
|
|
3213
|
+
ctx.client.send({
|
|
3214
|
+
type: "codex.interrupt.response",
|
|
3215
|
+
stream_id: msg.stream_id,
|
|
3216
|
+
request_id: msg.request_id ?? null,
|
|
3217
|
+
...result,
|
|
3218
|
+
})
|
|
3219
|
+
}
|
|
3220
|
+
|
|
3197
3221
|
/**
|
|
3198
3222
|
* tmux.kill_session の処理 (症状3 根治)。要求された session を kill し、要求元へ
|
|
3199
3223
|
* tmux.kill_session.result を返したうえで、kill できた各 session について
|
|
@@ -4333,9 +4357,10 @@ async function dispatch(msg, ctx) {
|
|
|
4333
4357
|
})
|
|
4334
4358
|
return
|
|
4335
4359
|
case "codex.interrupt":
|
|
4336
|
-
|
|
4337
|
-
|
|
4338
|
-
|
|
4360
|
+
// fire-and-forget だが async なので、送信失敗を未処理 rejection にしない
|
|
4361
|
+
// (handleBundleSyncRequest と同じ作法)。停止経路が例外で無言死しないための保険。
|
|
4362
|
+
handleCodexInterrupt(msg, ctx).catch((err) => {
|
|
4363
|
+
ctx.logger?.warn?.({ err: err?.message || String(err) }, "codex.interrupt handler failed")
|
|
4339
4364
|
})
|
|
4340
4365
|
return
|
|
4341
4366
|
case "codex.detach":
|