feat: make mobile queue priority self-healing (Closes #1466)
All checks were successful
CI / lint (pull_request) Successful in 3m44s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 7m12s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-27 10:32:02 +00:00
parent c5b801460d
commit 1e8d27c18f
4 changed files with 168 additions and 31 deletions

View File

@ -122,12 +122,14 @@ sync service leaves ad-hoc Search usable. Set `STACKCHAIN_SAVED_SEARCH_DB` to ov
`.stackchain-state/saved-searches.sqlite3` path. `.stackchain-state/saved-searches.sqlite3` path.
The mobile **Customize routine order** control is also portable across authenticated devices. Reordering 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 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 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 losing either order. Account changes discard stale responses and retry work. Resetting publishes the
Prepare Today precedence are not customizable. Set `STACKCHAIN_QUEUE_PRIORITY_DB` to override the canonical default order. Delivery, Human Gates, and active Prepare Today precedence are not customizable.
default `.stackchain-state/queue-priority.sqlite3` path. 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 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. issue/pull-request details feed the mobile **Following** queue, including work already assigned to you or a teammate.

View File

@ -447,11 +447,10 @@
}, },
onChange: () => { onChange: () => {
renderMobileQueuePresentation(); renderMobileQueuePresentation();
void mobileQueuePriority.sync();
}, },
}); });
mobileQueuePriority.start(); mobileQueuePriority.start();
window.addEventListener('online', () => { void mobileQueuePriority.sync(); }); mobileQueuePriority.startLifecycle({window, document});
let rR = null; let rR = null;
function rRC() { function rRC() {
if (rR) return rR; if (rR) return rR;

View File

@ -11,8 +11,17 @@
const prefix = 'stackchain-mobile-queue-priority-v1:'; const prefix = 'stackchain-mobile-queue-priority-v1:';
const labels = options.labels || {}; const labels = options.labels || {};
const documentRef = options.document || (typeof document !== 'undefined' ? document : null); 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; let memory = null;
const syncFlights = new Map(); const syncFlights = new Map();
let debounceTimer = null;
let retryTimer = null;
let retryAccount = '';
let retryAttempts = 0;
function key() { function key() {
const login = String(getLogin() || '').trim().toLowerCase(); const login = String(getLogin() || '').trim().toLowerCase();
@ -90,6 +99,45 @@
if (!persist(value)) return false; if (!persist(value)) return false;
options.onChange?.(order.slice()); options.onChange?.(order.slice());
announce(value); 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; return true;
} }
@ -123,6 +171,7 @@
throw new Error('Queue priority response is invalid.'); throw new Error('Queue priority response is invalid.');
} }
const value = {revision:snapshotValue.revision, order:snapshotValue.order.slice(), pending:false, status:'ready', remote:null}; const value = {revision:snapshotValue.revision, order:snapshotValue.order.slice(), pending:false, status:'ready', remote:null};
clearRetry();
persist(value); persist(value);
options.onChange?.(value.order.slice()); options.onChange?.(value.order.slice());
announce(value); announce(value);
@ -180,10 +229,12 @@
const latest = read(); const latest = read();
const remote = error?.status === 409 && error?.payload?.detail?.snapshot; const remote = error?.status === 409 && error?.payload?.detail?.snapshot;
if (remote && valid(remote.order) && Number.isInteger(remote.revision)) { if (remote && valid(remote.order) && Number.isInteger(remote.revision)) {
clearRetry();
latest.status = 'conflict'; latest.pending = true; latest.status = 'conflict'; latest.pending = true;
latest.remote = {revision:remote.revision, order:remote.order.slice()}; latest.remote = {revision:remote.revision, order:remote.order.slice()};
} else { } else {
latest.status = 'pending'; latest.pending = true; latest.status = 'pending'; latest.pending = true;
if (transient(error)) scheduleRetry(accountKey);
} }
persist(latest); announce(latest); render(); persist(latest); announce(latest); render();
return snapshot(); return snapshot();
@ -193,6 +244,10 @@
} }
function sync() { function sync() {
if (debounceTimer) {
clearTimer(debounceTimer);
debounceTimer = null;
}
const accountKey = key(); const accountKey = key();
const current = read(); const current = read();
if (!fetchJson || !accountKey || !current.pending) return Promise.resolve(snapshot()); if (!fetchJson || !accountKey || !current.pending) return Promise.resolve(snapshot());
@ -266,6 +321,20 @@
return render(); 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()}; state:snapshot, defaultOrder:() => DEFAULT_ORDER.slice()};
}); });

View File

@ -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""" script = f"""
const createPriority = require({json.dumps(str(QUEUE_PRIORITY))}); const createPriority = require({json.dumps(str(QUEUE_PRIORITY))});
const values = new Map(); const pending=[]; const payloads=[]; const values = new Map(); const payloads=[]; const timers=[];
const fetchJson = async (_url, init) => new Promise(resolve => {{ const setTimer = (callback, delay) => {{ const timer={{callback,delay,cancelled:false}}; timers.push(timer); return timer; }};
const payload=JSON.parse(init.body); payloads.push(payload); pending.push(() => resolve({{revision:payload.revision+1,order:payload.order}})); 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 () => {{ (async () => {{
let priority; const priority=createPriority({{
priority=createPriority({{
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}}, 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); for (let index=0; index<4; index += 1) priority.move('following', -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)); await new Promise(resolve => setImmediate(resolve));
const firstPending=pending.length; process.stdout.write(JSON.stringify({{before,active:active.map(timer=>timer.delay),payloads,state:priority.state()}}));
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()}}));
}})().catch(error=>{{console.error(error);process.exit(1);}}); }})().catch(error=>{{console.error(error);process.exit(1);}});
""" """
result = subprocess.run(["node", "-e", script], capture_output=True, text=True) result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr assert result.returncode == 0, result.stderr
payload = json.loads(result.stdout) payload = json.loads(result.stdout)
assert payload["firstPending"] == 1 assert payload["before"] == 0
assert payload["secondPending"] == 1 assert payload["active"] == [150]
assert len(payload["payloads"]) == 2 assert len(payload["payloads"]) == 1
assert payload["payloads"][0]["revision"] == 0 assert payload["payloads"][0]["revision"] == 0
assert payload["payloads"][1]["revision"] == 1 assert payload["payloads"][0]["order"] == payload["state"]["order"]
assert payload["payloads"][1]["order"] == payload["state"]["order"] assert payload["state"]["revision"] == 1
assert payload["state"]["revision"] == 2 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" 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 'id="use-remote-mobile-queue-priority"' in html
assert "fetchJson: fetchReviewJson" in html assert "fetchJson: fetchReviewJson" in html
assert "void mobileQueuePriority.load();" 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 "keepLocalButton: qs('#keep-local-mobile-queue-priority')" in html
assert "useRemoteButton: qs('#use-remote-mobile-queue-priority')" in html assert "useRemoteButton: qs('#use-remote-mobile-queue-priority')" in html
assert "error.payload = payload;" in html assert "error.payload = payload;" in html