@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 +1 -1
- package/src/__tests__/backfill.test.ts +32 -0
- package/src/backfill.mjs +16 -1
package/package.json
CHANGED
|
@@ -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
|
|
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
|
}
|