From 1e8d27c18f19d89915b3a31a54795d04c71ee6a9 Mon Sep 17 00:00:00 2001 From: timmy Date: Thu, 27 Aug 2026 10:32:02 +0000 Subject: [PATCH] feat: make mobile queue priority self-healing (Closes #1466) --- README.md | 10 +-- frontend/dashboard.js | 3 +- frontend/mobile-queue-priority.js | 71 +++++++++++++++++- tests/test_mobile_task_dock.py | 115 +++++++++++++++++++++++------- 4 files changed, 168 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 8ab89a8..a3a589f 100644 --- a/README.md +++ b/README.md @@ -122,12 +122,14 @@ sync service leaves ad-hoc Search usable. Set `STACKCHAIN_SAVED_SEARCH_DB` to ov `.stackchain-state/saved-searches.sqlite3` path. The mobile **Customize routine order** control is also portable across authenticated devices. Reordering -remains immediate when offline and is marked **Sync pending** until connectivity returns. The complete +remains immediate when offline and is marked **Sync pending** until connectivity returns. Rapid taps are +coalesced into one latest-order write; transient network or server failures retry automatically with bounded +backoff, and a clean phone refreshes the account order when it returns to the foreground. The complete routine order is stored as an encrypted, revisioned collection scoped to the confirmed Gitea login; a concurrent edit shows explicit **Keep this device** and **Use other device** actions instead of silently -losing either order. Resetting publishes the canonical default order. Delivery, Human Gates, and active -Prepare Today precedence are not customizable. Set `STACKCHAIN_QUEUE_PRIORITY_DB` to override the -default `.stackchain-state/queue-priority.sqlite3` path. +losing either order. Account changes discard stale responses and retry work. Resetting publishes the +canonical default order. Delivery, Human Gates, and active Prepare Today precedence are not customizable. +Set `STACKCHAIN_QUEUE_PRIORITY_DB` to override the default `.stackchain-state/queue-priority.sqlite3` path. Confirmed **Watch issue** and **Watch pull request** actions on open Search results and assigned My Work issue/pull-request details feed the mobile **Following** queue, including work already assigned to you or a teammate. diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 86b5e6f..d16a41d 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -447,11 +447,10 @@ }, onChange: () => { renderMobileQueuePresentation(); - void mobileQueuePriority.sync(); }, }); mobileQueuePriority.start(); - window.addEventListener('online', () => { void mobileQueuePriority.sync(); }); + mobileQueuePriority.startLifecycle({window, document}); let rR = null; function rRC() { if (rR) return rR; diff --git a/frontend/mobile-queue-priority.js b/frontend/mobile-queue-priority.js index b3e6b91..d344a7b 100644 --- a/frontend/mobile-queue-priority.js +++ b/frontend/mobile-queue-priority.js @@ -11,8 +11,17 @@ const prefix = 'stackchain-mobile-queue-priority-v1:'; const labels = options.labels || {}; const documentRef = options.document || (typeof document !== 'undefined' ? document : null); + const setTimer = options.setTimeout || setTimeout; + const clearTimer = options.clearTimeout || clearTimeout; + const debounceMs = Number.isFinite(options.debounceMs) ? Math.max(0, options.debounceMs) : 150; + const retryBaseMs = Number.isFinite(options.retryBaseMs) ? Math.max(1, options.retryBaseMs) : 1000; + const retryMaxMs = Number.isFinite(options.retryMaxMs) ? Math.max(retryBaseMs, options.retryMaxMs) : 30000; let memory = null; const syncFlights = new Map(); + let debounceTimer = null; + let retryTimer = null; + let retryAccount = ''; + let retryAttempts = 0; function key() { const login = String(getLogin() || '').trim().toLowerCase(); @@ -90,6 +99,45 @@ if (!persist(value)) return false; options.onChange?.(order.slice()); announce(value); + scheduleSync(); + return true; + } + + function scheduleSync() { + if (!fetchJson || !key()) return false; + if (debounceTimer) clearTimer(debounceTimer); + debounceTimer = setTimer(() => { + debounceTimer = null; + void sync(); + }, debounceMs); + return true; + } + + function clearRetry() { + if (retryTimer) clearTimer(retryTimer); + retryTimer = null; + retryAccount = ''; + retryAttempts = 0; + } + + function transient(error) { + const status = Number(error?.status) || 0; + return status === 0 || status === 408 || status === 425 || status === 429 || status >= 500; + } + + function scheduleRetry(accountKey) { + if (retryTimer && retryAccount !== accountKey) clearRetry(); + if (retryTimer || key() !== accountKey) return false; + const delay = Math.min(retryMaxMs, retryBaseMs * (2 ** retryAttempts)); + retryAttempts += 1; + retryAccount = accountKey; + retryTimer = setTimer(() => { + retryTimer = null; + retryAccount = ''; + const current = read(); + if (key() !== accountKey || !current.pending || current.status === 'conflict') return; + void sync(); + }, delay); return true; } @@ -123,6 +171,7 @@ throw new Error('Queue priority response is invalid.'); } const value = {revision:snapshotValue.revision, order:snapshotValue.order.slice(), pending:false, status:'ready', remote:null}; + clearRetry(); persist(value); options.onChange?.(value.order.slice()); announce(value); @@ -180,10 +229,12 @@ const latest = read(); const remote = error?.status === 409 && error?.payload?.detail?.snapshot; if (remote && valid(remote.order) && Number.isInteger(remote.revision)) { + clearRetry(); latest.status = 'conflict'; latest.pending = true; latest.remote = {revision:remote.revision, order:remote.order.slice()}; } else { latest.status = 'pending'; latest.pending = true; + if (transient(error)) scheduleRetry(accountKey); } persist(latest); announce(latest); render(); return snapshot(); @@ -193,6 +244,10 @@ } function sync() { + if (debounceTimer) { + clearTimer(debounceTimer); + debounceTimer = null; + } const accountKey = key(); const current = read(); if (!fetchJson || !accountKey || !current.pending) return Promise.resolve(snapshot()); @@ -266,6 +321,20 @@ return render(); } - return {getOrder, move, reset, render, start, load, sync, useLocal, useRemote, + function startLifecycle(lifecycle = {}) { + const windowObject = lifecycle.window; + const lifecycleDocument = lifecycle.document; + const reconcile = () => { + if (!key()) return Promise.resolve(snapshot()); + return read().pending ? sync() : load(); + }; + windowObject?.addEventListener?.('online', () => { void reconcile(); }); + lifecycleDocument?.addEventListener?.('visibilitychange', () => { + if (!lifecycleDocument.hidden) void reconcile(); + }); + return reconcile; + } + + return {getOrder, move, reset, render, start, startLifecycle, load, sync, scheduleSync, useLocal, useRemote, state:snapshot, defaultOrder:() => DEFAULT_ORDER.slice()}; }); diff --git a/tests/test_mobile_task_dock.py b/tests/test_mobile_task_dock.py index eb04e8f..fcde5b2 100644 --- a/tests/test_mobile_task_dock.py +++ b/tests/test_mobile_task_dock.py @@ -149,42 +149,109 @@ const fetchJson = async (url, init={{}}) => {{ ] -def test_mobile_queue_priority_serializes_rapid_edits_and_publishes_latest_order(): +def test_mobile_queue_priority_coalesces_rapid_edits_and_publishes_latest_order(): script = f""" const createPriority = require({json.dumps(str(QUEUE_PRIORITY))}); -const values = new Map(); const pending=[]; const payloads=[]; -const fetchJson = async (_url, init) => new Promise(resolve => {{ - const payload=JSON.parse(init.body); payloads.push(payload); pending.push(() => resolve({{revision:payload.revision+1,order:payload.order}})); -}}); +const values = new Map(); const payloads=[]; const timers=[]; +const setTimer = (callback, delay) => {{ const timer={{callback,delay,cancelled:false}}; timers.push(timer); return timer; }}; +const clearTimer = timer => {{ if (timer) timer.cancelled=true; }}; +const fetchJson = async (_url, init) => {{ + const payload=JSON.parse(init.body); payloads.push(payload); + return {{revision:payload.revision+1,order:payload.order}}; +}}; (async () => {{ - let priority; - priority=createPriority({{ + const priority=createPriority({{ storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}}, - getLogin:()=>'alice', fetchJson, onChange:()=>{{ void priority.sync(); }}, + getLogin:()=>'alice', fetchJson, setTimeout:setTimer, clearTimeout:clearTimer, debounceMs:150, }}); - priority.move('following', -1); - priority.move('following', -1); + for (let index=0; index<4; index += 1) priority.move('following', -1); + const before=payloads.length; + const active=timers.filter(timer=>!timer.cancelled); + active[0].callback(); await new Promise(resolve => setImmediate(resolve)); - const firstPending=pending.length; - pending.shift()(); - await new Promise(resolve => setImmediate(resolve)); - const secondPending=pending.length; - pending.shift()(); - await new Promise(resolve => setImmediate(resolve)); - process.stdout.write(JSON.stringify({{firstPending,secondPending,payloads,state:priority.state()}})); + process.stdout.write(JSON.stringify({{before,active:active.map(timer=>timer.delay),payloads,state:priority.state()}})); }})().catch(error=>{{console.error(error);process.exit(1);}}); """ result = subprocess.run(["node", "-e", script], capture_output=True, text=True) assert result.returncode == 0, result.stderr payload = json.loads(result.stdout) - assert payload["firstPending"] == 1 - assert payload["secondPending"] == 1 - assert len(payload["payloads"]) == 2 + assert payload["before"] == 0 + assert payload["active"] == [150] + assert len(payload["payloads"]) == 1 assert payload["payloads"][0]["revision"] == 0 - assert payload["payloads"][1]["revision"] == 1 - assert payload["payloads"][1]["order"] == payload["state"]["order"] - assert payload["state"]["revision"] == 2 + assert payload["payloads"][0]["order"] == payload["state"]["order"] + assert payload["state"]["revision"] == 1 + assert payload["state"]["status"] == "ready" + + +def test_mobile_queue_priority_retries_transient_failure_without_another_edit(): + script = f""" +const createPriority = require({json.dumps(str(QUEUE_PRIORITY))}); +const values = new Map(); const timers=[]; let calls=0; +const setTimer = (callback, delay) => {{ const timer={{callback,delay,cancelled:false}}; timers.push(timer); return timer; }}; +const clearTimer = timer => {{ if (timer) timer.cancelled=true; }}; +const fetchJson = async (_url, init) => {{ + calls += 1; + if (calls === 1) {{ const error=new Error('temporary'); error.status=503; throw error; }} + const payload=JSON.parse(init.body); + return {{revision:payload.revision+1,order:payload.order}}; +}}; +(async () => {{ + const priority=createPriority({{ + storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}}, + getLogin:()=>'alice', fetchJson, setTimeout:setTimer, clearTimeout:clearTimer, + debounceMs:150, retryBaseMs:1000, retryMaxMs:30000, + }}); + priority.move('following', -1); + timers.find(timer=>!timer.cancelled).callback(); + await new Promise(resolve => setImmediate(resolve)); + const afterFailure=priority.state(); + const retry=timers.filter(timer=>!timer.cancelled).at(-1); + retry.callback(); + await new Promise(resolve => setImmediate(resolve)); + process.stdout.write(JSON.stringify({{calls,afterFailure,retryDelay:retry.delay,settled:priority.state()}})); +}})().catch(error=>{{console.error(error);process.exit(1);}}); +""" + result = subprocess.run(["node", "-e", script], capture_output=True, text=True) + + assert result.returncode == 0, result.stderr + payload = json.loads(result.stdout) + assert payload["afterFailure"]["pending"] is True + assert payload["afterFailure"]["status"] == "pending" + assert payload["retryDelay"] == 1000 + assert payload["calls"] == 2 + assert payload["settled"]["pending"] is False + assert payload["settled"]["status"] == "ready" + + +def test_mobile_queue_priority_refreshes_clean_device_when_foregrounded(): + script = f""" +const createPriority = require({json.dumps(str(QUEUE_PRIORITY))}); +const values = new Map(); const windowListeners={{}}; const documentListeners={{}}; +const remoteOrder=['attention','following','today','update','agenda','authored','filed','later','draft']; +let calls=0; +const documentRef={{hidden:false,addEventListener:(name, callback)=>documentListeners[name]=callback}}; +const windowRef={{addEventListener:(name, callback)=>windowListeners[name]=callback}}; +const priority=createPriority({{ + storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}}, + getLogin:()=>'alice', fetchJson:async () => {{ calls += 1; return {{revision:4,order:remoteOrder.slice()}}; }}, +}}); +(async () => {{ + priority.startLifecycle({{window:windowRef,document:documentRef}}); + documentListeners.visibilitychange(); + await new Promise(resolve => setImmediate(resolve)); + process.stdout.write(JSON.stringify({{calls,state:priority.state(),listeners:[...Object.keys(windowListeners),...Object.keys(documentListeners)]}})); +}})().catch(error=>{{console.error(error);process.exit(1);}}); +""" + result = subprocess.run(["node", "-e", script], capture_output=True, text=True) + + assert result.returncode == 0, result.stderr + payload = json.loads(result.stdout) + assert set(payload["listeners"]) == {"online", "visibilitychange"} + assert payload["calls"] == 1 + assert payload["state"]["revision"] == 4 + assert payload["state"]["order"][1] == "following" assert payload["state"]["status"] == "ready" @@ -535,7 +602,7 @@ async def test_mobile_queue_priority_wires_cross_device_hydration_and_explicit_c assert 'id="use-remote-mobile-queue-priority"' in html assert "fetchJson: fetchReviewJson" in html assert "void mobileQueuePriority.load();" in html - assert "void mobileQueuePriority.sync();" in html + assert "mobileQueuePriority.startLifecycle({window, document});" in html assert "keepLocalButton: qs('#keep-local-mobile-queue-priority')" in html assert "useRemoteButton: qs('#use-remote-mobile-queue-priority')" in html assert "error.payload = payload;" in html -- 2.43.0