@gemus/mcp-proxy 0.1.1 → 0.1.2
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/__tests__/backfill.test.ts +85 -0
- package/src/backfill.mjs +33 -8
- package/src/proxy.mjs +87 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gemus/mcp-proxy",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Local stdio<->HTTP MCP proxy for Codex desktop: transparent passthrough + execute tap + imagegen backfill (Issue #1751, P1).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
27
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
28
|
-
"@gemus/codex-backfill-core": "0.1.
|
|
28
|
+
"@gemus/codex-backfill-core": "0.1.1"
|
|
29
29
|
},
|
|
30
30
|
"engines": {
|
|
31
31
|
"node": ">=18"
|
|
@@ -11,6 +11,7 @@ const mocks = vi.hoisted(() => ({
|
|
|
11
11
|
addImageToCanvas: vi.fn(),
|
|
12
12
|
readRollout: vi.fn(),
|
|
13
13
|
findRolloutFile: vi.fn(),
|
|
14
|
+
readGeneratedImageFiles: vi.fn(),
|
|
14
15
|
}))
|
|
15
16
|
|
|
16
17
|
vi.mock('@gemus/codex-backfill-core', async (importOriginal) => {
|
|
@@ -21,6 +22,7 @@ vi.mock('@gemus/codex-backfill-core', async (importOriginal) => {
|
|
|
21
22
|
addImageToCanvas: mocks.addImageToCanvas,
|
|
22
23
|
readRollout: mocks.readRollout,
|
|
23
24
|
findRolloutFile: mocks.findRolloutFile,
|
|
25
|
+
readGeneratedImageFiles: mocks.readGeneratedImageFiles,
|
|
24
26
|
}
|
|
25
27
|
})
|
|
26
28
|
|
|
@@ -71,6 +73,8 @@ beforeEach(() => {
|
|
|
71
73
|
mocks.addImageToCanvas.mockReset().mockResolvedValue({ ok: true, entityId: 'agent_x' })
|
|
72
74
|
mocks.readRollout.mockReset().mockReturnValue('')
|
|
73
75
|
mocks.findRolloutFile.mockReset().mockReturnValue('/fake/rollout.jsonl')
|
|
76
|
+
// Default: generated_images unavailable → backfill falls back to the rollout source (existing tests).
|
|
77
|
+
mocks.readGeneratedImageFiles.mockReset().mockReturnValue({ available: false, images: [] })
|
|
74
78
|
})
|
|
75
79
|
|
|
76
80
|
describe('normalizeToolName (tap → tracker)', () => {
|
|
@@ -255,3 +259,84 @@ describe('backfillTurn — idempotency + guards', () => {
|
|
|
255
259
|
expect(mocks.addImageToCanvas).not.toHaveBeenCalled()
|
|
256
260
|
})
|
|
257
261
|
})
|
|
262
|
+
|
|
263
|
+
describe('backfillTurn — footprint-0 call passthrough + timeout semantics (#1756 Layer 1a)', () => {
|
|
264
|
+
it('threads the injected `call` into deliver AND orphan (footprint-0)', async () => {
|
|
265
|
+
const call = vi.fn()
|
|
266
|
+
const bf = createBackfiller({
|
|
267
|
+
server: 'http://localhost:3000',
|
|
268
|
+
apiKey: 'mak_test',
|
|
269
|
+
codexHome: '/fake/.codex',
|
|
270
|
+
call,
|
|
271
|
+
})
|
|
272
|
+
observeExecuteAnchor(bf, 1, 't1', 's1', 'wf_real', 'nodeA')
|
|
273
|
+
mocks.readRollout.mockReturnValue(rollout([IMG_A]))
|
|
274
|
+
mocks.deliverImageToNode.mockResolvedValue({ ok: false, code: 'ALREADY_COMPLETED' }) // force orphan fallback
|
|
275
|
+
|
|
276
|
+
await bf.backfillTurn({ turnKey: 't1', sessionId: 's1', rolloutPath: '/x', salvageOnly: false })
|
|
277
|
+
|
|
278
|
+
expect(mocks.deliverImageToNode.mock.calls[0][0]).toMatchObject({ call })
|
|
279
|
+
expect(mocks.addImageToCanvas.mock.calls[0][0]).toMatchObject({ call })
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
it('a timed-out deliver leaves the image FRESH — no orphan, not marked seen (no duplicate)', async () => {
|
|
283
|
+
const bf = makeBackfiller()
|
|
284
|
+
observeExecuteAnchor(bf, 1, 't1', 's1', 'wf_real', 'nodeA')
|
|
285
|
+
mocks.readRollout.mockReturnValue(rollout([IMG_A]))
|
|
286
|
+
// deliver times out → result unknown; the platform job may still land, so we must NOT orphan.
|
|
287
|
+
mocks.deliverImageToNode.mockResolvedValue({ ok: false, timedOut: true, error: 'inject timeout' })
|
|
288
|
+
|
|
289
|
+
const r1 = await bf.backfillTurn({ turnKey: 't1', sessionId: 's1', rolloutPath: '/x', salvageOnly: false })
|
|
290
|
+
expect(r1).toEqual({ delivered: 0, orphaned: 0, dropped: 0 })
|
|
291
|
+
expect(mocks.addImageToCanvas).not.toHaveBeenCalled() // NO orphan on timeout
|
|
292
|
+
|
|
293
|
+
// Image stayed fresh (not seen): a later Stop-hook pass can still deliver it once the job lands.
|
|
294
|
+
mocks.deliverImageToNode.mockResolvedValue({ ok: true, status: 'processing' })
|
|
295
|
+
const r2 = await bf.backfillTurn({ turnKey: 't1', sessionId: 's1', rolloutPath: '/x', salvageOnly: false })
|
|
296
|
+
expect(r2.delivered).toBe(1)
|
|
297
|
+
})
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
describe('backfillTurn — Layer 2 file byte source (#1756)', () => {
|
|
301
|
+
it('uses generated_images when available and does NOT read the rollout', async () => {
|
|
302
|
+
const bf = makeBackfiller()
|
|
303
|
+
observeExecuteAnchor(bf, 1, 't1', 's1', 'wf_real', 'nodeA')
|
|
304
|
+
// generated_images available with one fresh image → file source wins, rollout untouched.
|
|
305
|
+
mocks.readGeneratedImageFiles.mockReturnValue({
|
|
306
|
+
available: true,
|
|
307
|
+
images: [{ callId: 'ig_a', base64: 'iVBORw0KGgoAAAA1', mimeType: 'image/png' }],
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
const r = await bf.backfillTurn({ turnKey: 't1', sessionId: 's1', rolloutPath: '/x', salvageOnly: false })
|
|
311
|
+
|
|
312
|
+
expect(mocks.readGeneratedImageFiles).toHaveBeenCalled()
|
|
313
|
+
expect(mocks.readRollout).not.toHaveBeenCalled() // file source is authoritative when available
|
|
314
|
+
expect(r.delivered).toBe(1)
|
|
315
|
+
expect(mocks.deliverImageToNode.mock.calls[0][0]).toMatchObject({ nodeId: 'nodeA', image: { callId: 'ig_a' } })
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
it('available but empty → no delivery, and does NOT fall back to rollout (mtime guard preserved)', async () => {
|
|
319
|
+
const bf = makeBackfiller()
|
|
320
|
+
observeExecuteAnchor(bf, 1, 't1', 's1', 'wf_real', 'nodeA')
|
|
321
|
+
mocks.readGeneratedImageFiles.mockReturnValue({ available: true, images: [] })
|
|
322
|
+
// Even though the rollout has an image, availability=true must lock us to the (empty) file source.
|
|
323
|
+
mocks.readRollout.mockReturnValue(rollout([IMG_A]))
|
|
324
|
+
|
|
325
|
+
const r = await bf.backfillTurn({ turnKey: 't1', sessionId: 's1', rolloutPath: '/x', salvageOnly: false })
|
|
326
|
+
|
|
327
|
+
expect(mocks.readRollout).not.toHaveBeenCalled()
|
|
328
|
+
expect(r).toEqual({ delivered: 0, orphaned: 0, dropped: 0 })
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
it('unavailable → falls back to rollout-scrape (older codex)', async () => {
|
|
332
|
+
const bf = makeBackfiller()
|
|
333
|
+
observeExecuteAnchor(bf, 1, 't1', 's1', 'wf_real', 'nodeA')
|
|
334
|
+
mocks.readGeneratedImageFiles.mockReturnValue({ available: false, images: [] })
|
|
335
|
+
mocks.readRollout.mockReturnValue(rollout([IMG_A]))
|
|
336
|
+
|
|
337
|
+
const r = await bf.backfillTurn({ turnKey: 't1', sessionId: 's1', rolloutPath: '/x', salvageOnly: false })
|
|
338
|
+
|
|
339
|
+
expect(mocks.readRollout).toHaveBeenCalledWith('/x')
|
|
340
|
+
expect(r.delivered).toBe(1)
|
|
341
|
+
})
|
|
342
|
+
})
|
package/src/backfill.mjs
CHANGED
|
@@ -16,6 +16,10 @@
|
|
|
16
16
|
// a later trigger no-ops. salvageOnly NEVER calls claim() — mirrors bridge salvage semantics (图不丢, 不认领).
|
|
17
17
|
import * as core from '@gemus/codex-backfill-core'
|
|
18
18
|
|
|
19
|
+
// #1756 Layer 2 mtime cutoff:只认本进程启动后落盘的 generated_images 文件,防 proxy 重启后把该
|
|
20
|
+
// session 历史图当 fresh 重复交付(session_id 跨 resume 复用、seen 是进程内存)。模块加载≈proxy 启动。
|
|
21
|
+
const procStartMs = Date.now()
|
|
22
|
+
|
|
19
23
|
/**
|
|
20
24
|
* Normalize a raw MCP wire tool name to the tracker's expected form. The proxy sees `execute` /
|
|
21
25
|
* `batch_execute` on the wire; ExecuteClaimTracker only matches `mcp__gemus__execute` /
|
|
@@ -71,9 +75,12 @@ export function injectSteering(msg) {
|
|
|
71
75
|
* @param {string} deps.server 平台 origin(不含 /api/mcp)
|
|
72
76
|
* @param {string} deps.apiKey 用户 mak_ key
|
|
73
77
|
* @param {string} deps.codexHome ~/.codex(idle-timer fallback 用 findRolloutFile(session_id) 定位 rollout)
|
|
78
|
+
* @param {import('@gemus/codex-backfill-core').GemusToolCall} [deps.call] footprint-0 交付 transport
|
|
79
|
+
* (#1756 Layer 1a):省略 → connect-per-call(每图新开 key-only session);传入 → 在 proxy 已有
|
|
80
|
+
* upstream 连接上注入 execute/canvas_edit(不占新槽,即便 cap 被泄漏填满仍能交付)。
|
|
74
81
|
* @param {(msg: string, obj?: unknown) => void} [deps.log]
|
|
75
82
|
*/
|
|
76
|
-
export function createBackfiller({ server, apiKey, codexHome, log = () => {} }) {
|
|
83
|
+
export function createBackfiller({ server, apiKey, codexHome, call, log = () => {} }) {
|
|
77
84
|
/** @type {Map<string, { tracker: InstanceType<typeof core.ExecuteClaimTracker>, workflowId?: string, sessionId?: string }>} */
|
|
78
85
|
const turns = new Map() // keyed by turn_id; bounded (evict oldest) so a long session can't leak
|
|
79
86
|
const TURN_CAP = 64
|
|
@@ -123,7 +130,7 @@ export function createBackfiller({ server, apiKey, codexHome, log = () => {} })
|
|
|
123
130
|
|
|
124
131
|
/** orphan 一张图(Mode B 需真实 workflowId),**只在成功后**标记 seen;返回是否成功。 */
|
|
125
132
|
async function orphanOne(workflowId, image) {
|
|
126
|
-
const r = await core.addImageToCanvas({ server, apiKey, workflowId, image })
|
|
133
|
+
const r = await core.addImageToCanvas({ server, apiKey, workflowId, image, call })
|
|
127
134
|
if (r.ok) {
|
|
128
135
|
seen.add(image.callId)
|
|
129
136
|
return true
|
|
@@ -143,14 +150,27 @@ export function createBackfiller({ server, apiKey, codexHome, log = () => {} })
|
|
|
143
150
|
async function backfillTurnInner({ turnKey, sessionId, rolloutPath, salvageOnly }) {
|
|
144
151
|
const t = turns.get(turnKey)
|
|
145
152
|
const sid = sessionId || t?.sessionId
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
153
|
+
// #1756 Layer 2: 优先读 generated_images 落盘文件(去 rollout-scrape 脆化)。三态 fallback——
|
|
154
|
+
// 仅目录整体不可用(旧 codex 不落盘)才回退 rollout;目录可用即锁定文件源(即便过滤后空),
|
|
155
|
+
// 否则会绕过 mtime 守卫把历史图从 rollout 重新捞出重复交付。
|
|
156
|
+
const fileResult = sid
|
|
157
|
+
? core.readGeneratedImageFiles(codexHome, sid, procStartMs)
|
|
158
|
+
: { available: false, images: [] }
|
|
159
|
+
let all
|
|
160
|
+
let source
|
|
161
|
+
if (fileResult.available) {
|
|
162
|
+
all = fileResult.images
|
|
163
|
+
source = 'generated_images'
|
|
164
|
+
} else {
|
|
165
|
+
const path = rolloutPath || core.findRolloutFile(codexHome, sid)
|
|
166
|
+
all = core.extractImageOutputs(core.readRollout(path))
|
|
167
|
+
source = path || '(none)'
|
|
168
|
+
}
|
|
149
169
|
const fresh = all.filter((img) => !seen.has(img.callId))
|
|
150
170
|
|
|
151
171
|
if (fresh.length === 0) {
|
|
152
172
|
// 图还没 flush(idle 过早触发)或都已交付 —— 都不动 seen/turn 状态,留给后续 hook。
|
|
153
|
-
log('backfill: no fresh images', { turnKey, salvageOnly,
|
|
173
|
+
log('backfill: no fresh images', { turnKey, salvageOnly, source })
|
|
154
174
|
return { delivered: 0, orphaned: 0, dropped: 0 }
|
|
155
175
|
}
|
|
156
176
|
|
|
@@ -175,12 +195,17 @@ export function createBackfiller({ server, apiKey, codexHome, log = () => {} })
|
|
|
175
195
|
let delivered = 0
|
|
176
196
|
let orphaned = 0
|
|
177
197
|
for (const { nodeId, image } of claims) {
|
|
178
|
-
const r = await core.deliverImageToNode({ server, apiKey, workflowId, nodeId, image })
|
|
198
|
+
const r = await core.deliverImageToNode({ server, apiKey, workflowId, nodeId, image, call })
|
|
179
199
|
if (r.ok) {
|
|
180
200
|
seen.add(image.callId)
|
|
181
201
|
delivered++
|
|
202
|
+
} else if (r.timedOut) {
|
|
203
|
+
// #1756: 注入交付超时 = 结果未知(平台 job 可能仍在跑并最终写 DB)。**不 orphan、不标 seen**——
|
|
204
|
+
// 否则同图既落节点又多一个 image-upload 孤儿。留 fresh,交给下轮 pass(Stop hook/idle)在 job
|
|
205
|
+
// 落地后经 execute 幂等门重判(already_completed 才安全 orphan)。
|
|
206
|
+
log('backfill: deliver timed out — leaving image fresh (no orphan)', { nodeId, callId: image.callId })
|
|
182
207
|
} else if (await orphanOne(workflowId, image)) {
|
|
183
|
-
// already_completed /
|
|
208
|
+
// already_completed / 其它确定性失败 → orphan 保底(镜像 bridge:新图未落节点则至少 image-upload)。
|
|
184
209
|
orphaned++
|
|
185
210
|
} else {
|
|
186
211
|
log('backfill: deliver+orphan both failed', { nodeId, callId: image.callId, error: r.error ?? r.code })
|
package/src/proxy.mjs
CHANGED
|
@@ -24,6 +24,7 @@ import path from 'node:path'
|
|
|
24
24
|
import fs from 'node:fs'
|
|
25
25
|
import { randomBytes } from 'node:crypto'
|
|
26
26
|
import { createBackfiller, injectSteering } from './backfill.mjs'
|
|
27
|
+
import { unwrapMcpContent } from '@gemus/codex-backfill-core'
|
|
27
28
|
|
|
28
29
|
const KEY = process.env.GEMUS_KEY || ''
|
|
29
30
|
const GEMUS_URL = process.env.GEMUS_URL || 'https://gemus.ai/api/mcp'
|
|
@@ -33,16 +34,68 @@ const CODEX_HOME = process.env.CODEX_HOME || path.join(os.homedir(), '.codex')
|
|
|
33
34
|
// calls, so a short idle looks like "turn ended" mid-generation and fires prematurely. A trusted Stop
|
|
34
35
|
// hook fires at turn end and clears this timer, so trusted users never hit it (#1751 real-machine fix).
|
|
35
36
|
const IDLE_MS = Number(process.env.PROXY_BACKFILL_IDLE_MS) || 180_000
|
|
37
|
+
// footprint-0 注入交付超时(#1756 Layer 1a)。必须 ≥ 平台对 codex-imagen 回填节点的同步等待上限
|
|
38
|
+
// (deriveWaitTimeoutMs('gen-image-generation')=180s,见 src/server/generation/waitTimeout.ts),否则
|
|
39
|
+
// proxy 先超时 → 假失败 → orphan 回退 → 同图重复交付。240s = 180s + 余量。别调到 180s 以下。
|
|
40
|
+
const INJECT_TIMEOUT_MS = Number(process.env.PROXY_INJECT_TIMEOUT_MS) || 240_000
|
|
36
41
|
const LOG = process.env.PROXY_LOG // optional debug log path
|
|
37
42
|
const log = (dir, m) => {
|
|
38
43
|
if (!LOG) return
|
|
39
44
|
fs.appendFileSync(LOG, `[${new Date().toISOString()}] ${dir} ${typeof m === 'string' ? m : JSON.stringify(m).slice(0, 1000)}\n`)
|
|
40
45
|
}
|
|
41
46
|
|
|
47
|
+
// ── footprint-0 交付注入(#1756 Layer 1a)─────────────────────────────────────────────────────────
|
|
48
|
+
// 交付/orphan 不再每图新开 key-only session(connect-per-call,抢 KEY_ONLY_SESSION_CAP 槽),而是把
|
|
49
|
+
// execute/canvas_edit 的 tools/call **注入到 proxy 已建好、已计入 cap 的那条 upstream 连接**上(footprint
|
|
50
|
+
// 0)——即便 cap 被泄漏填满也能交付。注入用字符串 id 命名空间 `gemus-bf-*`(绝不撞 Codex 的数字 id),
|
|
51
|
+
// 响应在 upstream.onmessage 里按 id 自消费、不转发给 Codex。`upstream` 在下方声明,注入在 turn 末才调,
|
|
52
|
+
// 故闭包内按调用时解析、无 TDZ 问题。
|
|
53
|
+
/** in-flight 注入:id(string) → { settle }。settle 幂等收敛(响应/超时/取消/关停任一先到即结算)。 */
|
|
54
|
+
const pendingInjections = new Map()
|
|
55
|
+
let injectSeq = 0
|
|
56
|
+
|
|
57
|
+
/** 把 JSON-RPC 响应转成与 core `callGemusTool` 逐字一致的 CallGemusToolResult 形状。 */
|
|
58
|
+
function interpretInjection(m) {
|
|
59
|
+
if (m?.error) return { ok: false, error: m.error.message || `MCP error ${m.error.code}` }
|
|
60
|
+
const result = m?.result
|
|
61
|
+
if (result && result.isError === true) {
|
|
62
|
+
const first = Array.isArray(result.content) ? result.content[0] : undefined
|
|
63
|
+
return { ok: false, error: (first && first.text) || 'MCP tool error' }
|
|
64
|
+
}
|
|
65
|
+
return { ok: true, result: unwrapMcpContent(result && result.content) }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** @type {import('@gemus/codex-backfill-core').GemusToolCall} */
|
|
69
|
+
function injectedCall(toolName, args, opts = {}) {
|
|
70
|
+
const { progressToken, signal } = opts
|
|
71
|
+
const id = `gemus-bf-${++injectSeq}`
|
|
72
|
+
const req = {
|
|
73
|
+
jsonrpc: '2.0',
|
|
74
|
+
id,
|
|
75
|
+
method: 'tools/call',
|
|
76
|
+
params: { name: toolName, arguments: args, ...(progressToken ? { _meta: { progressToken } } : {}) },
|
|
77
|
+
}
|
|
78
|
+
return new Promise((resolve) => {
|
|
79
|
+
const settle = (v) => {
|
|
80
|
+
clearTimeout(timer)
|
|
81
|
+
if (signal) signal.removeEventListener('abort', onAbort)
|
|
82
|
+
pendingInjections.delete(id)
|
|
83
|
+
resolve(v)
|
|
84
|
+
}
|
|
85
|
+
const onAbort = () => settle({ ok: false, error: 'aborted' })
|
|
86
|
+
const timer = setTimeout(() => settle({ ok: false, timedOut: true, error: 'inject timeout' }), INJECT_TIMEOUT_MS)
|
|
87
|
+
pendingInjections.set(id, { settle })
|
|
88
|
+
if (signal) signal.addEventListener('abort', onAbort)
|
|
89
|
+
upstream.send(req).catch((e) => settle({ ok: false, error: String(e) }))
|
|
90
|
+
log('INJECT', { id, toolName, progressToken })
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
|
|
42
94
|
const backfiller = createBackfiller({
|
|
43
95
|
server: SERVER_ORIGIN,
|
|
44
96
|
apiKey: KEY,
|
|
45
97
|
codexHome: CODEX_HOME,
|
|
98
|
+
call: injectedCall,
|
|
46
99
|
log: (msg, obj) => log('BACKFILL', obj ? `${msg} ${JSON.stringify(obj)}` : msg),
|
|
47
100
|
})
|
|
48
101
|
|
|
@@ -196,18 +249,49 @@ const upstream = new StreamableHTTPClientTransport(new URL(GEMUS_URL), {
|
|
|
196
249
|
const stdio = new StdioServerTransport()
|
|
197
250
|
|
|
198
251
|
stdio.onmessage = (m) => { tapClientToServer(m); log('C->G', m); upstream.send(m).catch((e) => log('ERR-http-send', String(e))) }
|
|
199
|
-
upstream.onmessage = (m) => {
|
|
252
|
+
upstream.onmessage = (m) => {
|
|
253
|
+
// footprint-0 (#1756): our injected execute/canvas_edit responses carry a `gemus-bf-*` string id.
|
|
254
|
+
// Self-consume them here (settle the pending injection) and DO NOT forward to Codex — Codex never
|
|
255
|
+
// issued these calls, so it must never see the responses.
|
|
256
|
+
if (typeof m?.id === 'string' && m.id.startsWith('gemus-bf-')) {
|
|
257
|
+
const p = pendingInjections.get(m.id)
|
|
258
|
+
if (p) p.settle(interpretInjection(m))
|
|
259
|
+
return
|
|
260
|
+
}
|
|
261
|
+
// #1756 liveness heartbeat: our own `ping` pongs (gemus-ping-*) are self-consumed — Codex never
|
|
262
|
+
// issued them, so it must never see the responses.
|
|
263
|
+
if (typeof m?.id === 'string' && m.id.startsWith('gemus-ping-')) return
|
|
264
|
+
tapServerToClient(m); steerOnInit(m); log('G->C', m); stdio.send(m).catch((e) => log('ERR-stdio-send', String(e)))
|
|
265
|
+
}
|
|
200
266
|
|
|
201
267
|
// ── session hygiene (spike: KEY_ONLY_SESSION_CAP=3) ────────────────────────────────────────────
|
|
202
268
|
// We must DELETE the upstream session or we leak a key-only slot when codex ends the conversation.
|
|
203
269
|
// NOTE: transport.close() does NOT issue DELETE (it only aborts + fires onclose, streamableHttp.js:280);
|
|
204
270
|
// terminateSession() sends the DELETE (:431). Call it first, then close(). Also clean up local artifacts.
|
|
271
|
+
// #1756 proxy-death detection: send an MCP `ping` every 30s so the platform can tell a live proxy
|
|
272
|
+
// (keeps pinging) from a dead one (hard-killed → no graceful DELETE → leaked session). This is
|
|
273
|
+
// **independent of Codex tool activity**, so a proxy mid-imagegen (silent to Codex for minutes) still
|
|
274
|
+
// proves liveness → the platform reclaims only genuinely-dead sessions (short floor), never a live one.
|
|
275
|
+
const HEARTBEAT_MS = Number(process.env.PROXY_HEARTBEAT_MS) || 30_000
|
|
276
|
+
let pingSeq = 0
|
|
277
|
+
let heartbeatTimer
|
|
278
|
+
function startHeartbeat() {
|
|
279
|
+
heartbeatTimer = setInterval(() => {
|
|
280
|
+
upstream.send({ jsonrpc: '2.0', id: `gemus-ping-${++pingSeq}`, method: 'ping' }).catch((e) => log('PING-ERR', String(e)))
|
|
281
|
+
}, HEARTBEAT_MS)
|
|
282
|
+
if (typeof heartbeatTimer.unref === 'function') heartbeatTimer.unref() // don't keep the process alive on this alone
|
|
283
|
+
}
|
|
284
|
+
|
|
205
285
|
let shuttingDown = false
|
|
206
286
|
async function shutdown() {
|
|
207
287
|
if (shuttingDown) return
|
|
208
288
|
shuttingDown = true
|
|
289
|
+
if (heartbeatTimer) clearInterval(heartbeatTimer)
|
|
209
290
|
for (const t of idleTimers.values()) clearTimeout(t)
|
|
210
291
|
idleTimers.clear()
|
|
292
|
+
// Settle any in-flight footprint-0 injections so a mid-delivery shutdown doesn't hang their awaits.
|
|
293
|
+
for (const p of pendingInjections.values()) p.settle({ ok: false, error: 'proxy shutting down' })
|
|
294
|
+
pendingInjections.clear()
|
|
211
295
|
try { hookServer.close() } catch { /* ignore */ }
|
|
212
296
|
if (discoveryFile) { try { fs.unlinkSync(discoveryFile) } catch { /* ignore */ } }
|
|
213
297
|
try { await upstream.terminateSession() } catch { /* ignore */ }
|
|
@@ -223,4 +307,5 @@ process.on('SIGINT', shutdown)
|
|
|
223
307
|
|
|
224
308
|
await upstream.start()
|
|
225
309
|
await stdio.start()
|
|
226
|
-
|
|
310
|
+
startHeartbeat()
|
|
311
|
+
log('STARTED', { GEMUS_URL, SERVER_ORIGIN, hasKey: Boolean(KEY), pid: process.pid, idleMs: IDLE_MS, heartbeatMs: HEARTBEAT_MS })
|