feat: resume interrupted Find Work batches (Closes #679)
CI / lint (pull_request) Successful in 1m25s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-12 21:02:25 +00:00
1 parent a0dd044995
commit 654ae8714e
5 files changed
+194 -35

No files matched your search

+99 -30
View File
@@ -5,8 +5,28 @@ function createBatchFindWork({
timeBudget = () => ({ capacity_minutes: null, planned_minutes: 0 }),
persistEstimate = () => {},
onProgress = () => {},
storage = typeof localStorage === 'undefined' ? null : localStorage,
owner = () => '',
}) {
let request = null;
const journalKey = () => 'stackchain.find-work-batch.v1.' + encodeURIComponent(String(owner() || ''));
function readJournal() {
if (!storage || !String(owner() || '')) return null;
try {
const value = JSON.parse(storage.getItem(journalKey()) || 'null');
return value?.owner === String(owner()) && Array.isArray(value.items) ? value : null;
} catch (_error) { return null; }
}
function writeJournal(value) {
if (!storage || !String(owner() || '')) return;
storage.setItem(journalKey(), JSON.stringify(value));
}
function clearJournal() {
if (storage && String(owner() || '')) storage.removeItem(journalKey());
}
function result(status, selected, available, queued = [], failed = []) {
return { status, selected, available, queued, failed };
@@ -16,6 +36,48 @@ function createBatchFindWork({
return String(item.repository || '') + '#' + String(item.number || '');
}
async function processJournal(journal) {
const selected = journal.items;
const queued = [];
const failed = [];
for (let index = 0; index < selected.length; index += 1) {
const entry = selected[index];
const item = entry.item;
if (entry.state === 'queued') {
queued.push(key(item));
onProgress({ status: 'running', processed: index + 1, selected: selected.length });
continue;
}
try {
const confirmed = entry.confirmed || await claim(item);
if (!entry.confirmed) {
entry.confirmed = confirmed;
entry.state = 'assigned';
writeJournal(journal);
}
const queueResult = await queue(confirmed);
if (queueResult === 'queued' || queueResult === 'exists') {
queued.push(key(item));
entry.state = 'queued';
if (Number.isInteger(entry.estimate) && entry.estimate > 0) {
persistEstimate(confirmed, entry.estimate);
}
writeJournal(journal);
} else {
failed.push({ key: key(item), reason: 'assigned but Today sync is unavailable', assigned: true });
}
} catch (error) {
failed.push({ key: key(item), reason: error?.message || 'assignment failed' });
}
onProgress({ status: 'running', processed: index + 1, selected: selected.length });
}
const outcome = result('complete', selected.length, journal.available, queued, failed);
if (!failed.length) clearJournal();
else writeJournal(journal);
onProgress({ ...outcome, processed: selected.length });
return outcome;
}
function run(items, estimates = {}) {
if (request) return request;
const selected = Array.isArray(items) ? items.slice() : [];
@@ -46,39 +108,46 @@ function createBatchFindWork({
return Promise.resolve(outcome);
}
}
request = (async () => {
const queued = [];
const failed = [];
for (let index = 0; index < selected.length; index += 1) {
const item = selected[index];
try {
const confirmed = await claim(item);
const queueResult = await queue(confirmed);
if (queueResult === 'queued' || queueResult === 'exists') {
queued.push(key(item));
if (Number.isInteger(estimates[key(item)]) && estimates[key(item)] > 0) {
persistEstimate(confirmed, estimates[key(item)]);
}
} else {
failed.push({
key: key(item),
reason: 'assigned but Today sync is unavailable',
assigned: true,
});
}
} catch (error) {
failed.push({ key: key(item), reason: error?.message || 'assignment failed' });
}
onProgress({ status: 'running', processed: index + 1, selected: selected.length });
}
const outcome = result('complete', selected.length, available, queued, failed);
onProgress({ ...outcome, processed: selected.length });
return outcome;
})().finally(() => { request = null; });
const journal = {
owner: String(owner() || ''), available,
items: selected.map(item => ({ item, estimate: estimates[key(item)] || null, state: 'pending' })),
};
writeJournal(journal);
request = processJournal(journal).finally(() => { request = null; });
return request;
}
return { run };
function resume() {
if (request) return request;
const journal = readJournal();
if (!journal) return Promise.resolve(null);
request = processJournal(journal).finally(() => { request = null; });
return request;
}
function mountRecovery(button, opener) {
const show = () => {
const journal = readJournal();
button.hidden = !journal;
if (journal) button.textContent = 'Resume ' + journal.items.filter(item => item.state !== 'queued').length + ' interrupted';
};
button.addEventListener('click', async () => {
button.disabled = true;
const outcome = await resume();
button.disabled = false;
show();
if (outcome) {
button.previousElementSibling.textContent = outcome.failed.length ?
outcome.failed.length + ' still need recovery.' : outcome.queued.length + ' queued · batch recovered.';
}
});
opener.addEventListener('click', show);
}
if (typeof document !== 'undefined') mountRecovery(
document.getElementById('resume-find-work-batch'), document.getElementById('find-work')
);
return { run, resume, mountRecovery };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createBatchFindWork;
+1
View File
@@ -468,6 +468,7 @@ textarea { resize: vertical; min-height: 120px; }
.find-work-batch-actions[hidden] { display:none; }
.find-work-batch-actions span { grid-column:1 / -1; }
.find-work-batch-actions button { min-height:44px; }
.find-work-batch-recovery { min-height:44px; width:100%; font-weight:700; }
.find-work-estimate-review { display:grid; gap:12px; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#101f36; }
.find-work-estimate-review[hidden] { display:none; }
.find-work-estimate-review > div:first-child, #find-work-estimate-list { display:grid; gap:8px; }
+3 -4
View File
@@ -36,9 +36,7 @@
const state = Object.fromEntries(panels.map((item) => [item.dataset.panelKey, item.open]));
try {
localStorage.setItem(PANEL_STATE_KEY, JSON.stringify(state));
} catch (e) {
console.warn('Panel state save failed', e);
}
} catch (_) {}
});
});
const mobileTaskButtons = Object.fromEntries(
@@ -1393,7 +1391,7 @@
const batchFindWork = createBatchFindWork({
capacity: () => Math.max(0, todayWork.limit - todayWork.read().length),
timeBudget: () => {
owner:()=>planningOwnerLogin,timeBudget:()=>{
const plan = todayWork.planning();
return {
capacity_minutes: plan.capacity_minutes,
@@ -4310,6 +4308,7 @@
input.focus();
});
qs('#cancel-find-work-selection').addEventListener('click', () => findWorkController.cancelSelection());
qs('#claim-selected-work').addEventListener('click', async event => {
if (todayWork.planning().capacity_minutes !== null) {
openFindWorkEstimateReview(findWorkController.selectedItems());
+2
View File
@@ -544,6 +544,8 @@
<button class="fill-find-work-today" id="fill-find-work-today" type="button"
aria-describedby="find-work-selection-status">Fill remaining Today slots</button>
<div id="find-work-status" class="small" aria-live="assertive">Open Find Work to load available issues.</div>
<button class="find-work-batch-recovery" id="resume-find-work-batch" type="button"
aria-describedby="find-work-status" hidden>Resume assigning &amp; queueing</button>
<div class="find-work-list" id="find-work-list"></div>
<button class="find-work-more" id="load-more-available" type="button" hidden>Load more available issues</button>
<section class="find-work-estimate-review" id="find-work-estimate-review" aria-labelledby="find-work-estimate-heading" hidden>