From ab329aa1261d0defb7f692e94888fb200eeca009 Mon Sep 17 00:00:00 2001 From: timmy Date: Wed, 26 Aug 2026 01:04:25 +0000 Subject: [PATCH] fix: isolate overlapping Human Gate loads --- frontend/human-gates.js | 4 ++++ tests/test_human_gates_frontend.py | 12 ++++++++++++ 2 files changed, 16 insertions(+) diff --git a/frontend/human-gates.js b/frontend/human-gates.js index 40ce754..3e7bd89 100644 --- a/frontend/human-gates.js +++ b/frontend/human-gates.js @@ -10,6 +10,7 @@ function createHumanGates(options = {}) { let reviewSnapshot = []; let reviewIndex = -1; let loadedAccountKey = ''; + let loadEpoch = 0; const decisionKeys = new Map(); let decisionFlight = null; @@ -80,6 +81,7 @@ function createHumanGates(options = {}) { } async function load() { + const epoch = ++loadEpoch; const accountKey = String(getAccountKey() || '').trim().toLowerCase(); if (accountKey !== loadedAccountKey) { loadedAccountKey = accountKey; @@ -92,12 +94,14 @@ function createHumanGates(options = {}) { if (cached) { queue = cached; render(); } try { const live = validSnapshot(await fetchJson('api/v1/human-gates')); + if (epoch !== loadEpoch || accountKey !== loadedAccountKey) return queue; if (!live) throw new Error('Human Gates response is invalid.'); queue = { pending_count: live.pending_count, items: live.items.slice() }; save(queue); render(); return queue; } catch (error) { + if (epoch !== loadEpoch || accountKey !== loadedAccountKey) return queue; if (!cached) throw error; setText(nodes.status, 'Offline cached gate list ยท reconnect before deciding.'); return queue; diff --git a/tests/test_human_gates_frontend.py b/tests/test_human_gates_frontend.py index df5a91e..e49efc2 100644 --- a/tests/test_human_gates_frontend.py +++ b/tests/test_human_gates_frontend.py @@ -94,6 +94,18 @@ const gates=createHumanGates({storage,getLogin:()=> 'timmy',getAccountKey:()=>ac assert "secret" not in output["html"] +def test_stale_account_load_cannot_overwrite_new_account_queue_or_cache(): + output = run_node(r""" +const values=new Map(); const storage={getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}; +let account='1:timmy', resolveA, resolveB; +const responseA=new Promise(resolve=>resolveA=resolve), responseB=new Promise(resolve=>resolveB=resolve); +const gates=createHumanGates({storage,getLogin:()=> 'timmy',getAccountKey:()=>account,isOnline:()=>true,nodes:{count:{},list:{innerHTML:''},status:{},panel:{}},location:{hash:''},fetchJson:()=>account.startsWith('1:')?responseA:responseB}); +(async()=>{const loadA=gates.load();account='2:timmy';const loadB=gates.load();resolveB({pending_count:1,items:[{id:'b',title:'B gate',candidate_hash:'bhash'}]});await loadB;resolveA({pending_count:1,items:[{id:'a-secret',title:'A secret',candidate_hash:'asecret'}]});await loadA;process.stdout.write(JSON.stringify({snapshot:gates.snapshot(),cached:JSON.parse(values.get('stackchain.human-gates.v1:2:timmy'))}));})(); +""") + assert [item["id"] for item in output["snapshot"]["items"]] == ["b"] + assert [item["id"] for item in output["cached"]["items"]] == ["b"] + + def test_decision_retry_reuses_the_same_idempotency_key(): output = run_node(r""" let attempts=0; const keys=[];