@gemus/mcp-proxy 0.1.0 → 0.1.1

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": "@gemus/mcp-proxy",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
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": {
@@ -213,6 +213,38 @@ describe('backfillTurn — idempotency + guards', () => {
213
213
  expect(r2.delivered).toBe(1)
214
214
  })
215
215
 
216
+ it('serializes concurrent passes so a racing salvage does not double-place an in-flight claim (#1751)', async () => {
217
+ // Real-machine bug (dev.log 1400–1544): a claim pass (Stop hook) and a salvage pass (idle timer)
218
+ // ran concurrently over the same session rollout. Because `seen` is only marked AFTER each slow
219
+ // awaited deliver, the salvage pass read the still-in-flight images as "fresh" and orphaned them —
220
+ // the same image landed BOTH on its planned gen node AND as a duplicate image-upload node.
221
+ const bf = makeBackfiller()
222
+ observeExecuteAnchor(bf, 1, 't1', 's1', 'wf_real', 'nodeA')
223
+ observeExecuteAnchor(bf, 2, 't1', 's1', 'wf_real', 'nodeB')
224
+ mocks.readRollout.mockReturnValue(rollout([IMG_A, IMG_B]))
225
+
226
+ // deliver resolves only when released — models the 6-20s awaited media job seen in the logs.
227
+ let releaseDeliver!: () => void
228
+ const gate = new Promise<void>((res) => {
229
+ releaseDeliver = res
230
+ })
231
+ mocks.deliverImageToNode.mockImplementation(async () => {
232
+ await gate
233
+ return { ok: true, status: 'processing' }
234
+ })
235
+
236
+ // Fire the claim pass (parks on the deliver gate), then a racing salvage pass — do NOT await between.
237
+ const claimPass = bf.backfillTurn({ turnKey: 't1', sessionId: 's1', rolloutPath: '/x', salvageOnly: false })
238
+ const salvagePass = bf.backfillTurn({ turnKey: 't1', rolloutPath: null, salvageOnly: true })
239
+ releaseDeliver()
240
+ const [claimResult, salvageResult] = await Promise.all([claimPass, salvagePass])
241
+
242
+ // Both images delivered to their planned nodes; the racing salvage placed NO duplicate orphans.
243
+ expect(claimResult).toEqual({ delivered: 2, orphaned: 0, dropped: 0 })
244
+ expect(salvageResult).toEqual({ delivered: 0, orphaned: 0, dropped: 0 })
245
+ expect(mocks.addImageToCanvas).not.toHaveBeenCalled()
246
+ })
247
+
216
248
  it('does nothing (no placement) when no workflowId was ever captured', async () => {
217
249
  const bf = makeBackfiller()
218
250
  // No tool call carried a workflowId this turn.
package/src/backfill.mjs CHANGED
@@ -140,7 +140,7 @@ export function createBackfiller({ server, apiKey, codexHome, log = () => {} })
140
140
  * timer 在生成中途误触发、或 session-cap 交付失败)绝不能烧掉图 —— 否则真正的 Stop hook 认领时
141
141
  * fresh 为空。seen 只在成功交付后加入;turn 状态保留供后续 authoritative Stop hook 认领。
142
142
  */
143
- async function backfillTurn({ turnKey, sessionId, rolloutPath, salvageOnly }) {
143
+ async function backfillTurnInner({ turnKey, sessionId, rolloutPath, salvageOnly }) {
144
144
  const t = turns.get(turnKey)
145
145
  const sid = sessionId || t?.sessionId
146
146
  const path = rolloutPath || core.findRolloutFile(codexHome, sid)
@@ -191,5 +191,20 @@ export function createBackfiller({ server, apiKey, codexHome, log = () => {} })
191
191
  return { delivered, orphaned, dropped }
192
192
  }
193
193
 
194
+ // Serialize backfillTurn. The Stop-hook claim pass and the idle-timer salvage pass can fire
195
+ // concurrently over the same session rollout; `seen` is only marked AFTER each slow awaited deliver,
196
+ // so a racing pass would read still-in-flight images as "fresh" and orphan them — the same image
197
+ // then lands BOTH on its planned gen node AND as a duplicate image-upload node (#1751 real-machine
198
+ // bug, dev.log 1400–1544). Running passes one-at-a-time makes `seen` authoritative for the next pass
199
+ // WITHOUT weakening the seen-on-success invariant (a failed deliver stays fresh, so a later hook still
200
+ // retries it). Passes are meant to be idempotent alternatives, not parallel work — serializing costs
201
+ // nothing but the (already-awaited) wait for the winner.
202
+ let tail = Promise.resolve()
203
+ function backfillTurn(opts) {
204
+ const result = tail.then(() => backfillTurnInner(opts))
205
+ tail = result.catch(() => {}) // advance the chain even if a pass throws; callers still see the rejection
206
+ return result
207
+ }
208
+
194
209
  return { observeCall, observeResult, backfillTurn, _turns: turns, _seen: seen }
195
210
  }