diff --git a/changelog.d/1564-stage-poll.fixed.md b/changelog.d/1564-stage-poll.fixed.md new file mode 100644 index 000000000..6a7c5725b --- /dev/null +++ b/changelog.d/1564-stage-poll.fixed.md @@ -0,0 +1 @@ +The workflow stage reads `workflow/state` once per 2-second tick while a run is live instead of twice — two effects were polling the same condition. A read the host refuses now shows its error on the stage and retries, instead of ending as an unhandled rejection and painting the stage as "No workflows yet.". ([#1564](https://github.com/ThinkFlowLab/FlyBot/issues/1564)) diff --git a/plugins/workflow/client.js b/plugins/workflow/client.js index 1d3fa976d..9ca67036d 100644 --- a/plugins/workflow/client.js +++ b/plugins/workflow/client.js @@ -1258,10 +1258,12 @@ window.__ModuleLoader__.load({ const stageCall = (name, body, fallback) => settledCall(call, name, body, fallback) + const [readError, setReadError] = useState('') + const refresh = async () => { - const value = await call('workflow/state') - if (value && Array.isArray(value.workflows)) setPack(value) - else setError(value?.error ?? 'The workflow host did not answer') + const value = await stageCall('workflow/state', undefined, 'The workflow host did not answer') + if (value && Array.isArray(value.workflows)) { setPack(value); setReadError('') } + else setReadError(value?.error ?? 'The workflow host did not answer') return value } @@ -1299,25 +1301,16 @@ window.__ModuleLoader__.load({ return () => { root.openWorkflowAssign = prev } }, []) + // One poll: while a run is live, for the Bot replies that advance nodes, and while a + // read error is showing, so a refused read is retried. Two effects used to test the + // same condition and both fired, so every tick read the whole store twice (#1564). + const live = (pack?.workflows ?? []).some(item => (item.activeCount ?? 0) > 0) useEffect(() => { - const live = (pack?.workflows ?? []).some(item => (item.activeCount ?? 0) > 0) - if (!live) return undefined + if (!live && !readError) return undefined let gone = false const timer = setInterval(() => { if (!gone) void refresh() }, 2000) return () => { gone = true; clearInterval(timer) } - }, [pack]) - - // Refresh when a workflow has active runs - poll for Bot replies advancing nodes - useEffect(() => { - const active = (pack?.workflows ?? []).filter(item => (item.activeCount ?? 0) > 0) - if (active.length === 0) return undefined - let gone = false - const poll = setInterval(() => { - if (gone) return - void refresh() - }, 2000) - return () => { gone = true; clearInterval(poll) } - }, [pack?.workflows?.some(item => (item.activeCount ?? 0) > 0)]) + }, [live, readError]) useEffect(() => { if (!openId || typeof document === 'undefined') return undefined @@ -1438,7 +1431,7 @@ window.__ModuleLoader__.load({ h('div', { className: 'wf-sec' }, h('span', null, tr('Workflows')), h('button', { type: 'button', className: 'wf-new', onClick: () => setCreating(true) }, tr('New'))), - workflows.length === 0 ? h('div', { className: 'wf-muted' }, tr('No workflows yet.')) : null, + workflows.length === 0 && !readError ? h('div', { className: 'wf-muted' }, tr('No workflows yet.')) : null, workflows.map(item => h('button', { key: item.id, type: 'button', className: `wf-row ${openId === item.id ? 'on' : ''}`, onClick: () => { setOpenId(item.id) }, @@ -1457,6 +1450,7 @@ window.__ModuleLoader__.load({ h('span', { className: 'wf-ico' }, glyph()), h('span', { className: 'wf-txt' }, h('span', { className: 'wf-t1' }, tr('Hand a task to a Workflow')))), error ? h('div', { className: 'wf-err', role: 'alert' }, error) : null, + readError ? h('div', { className: 'wf-err', role: 'alert' }, readError) : null, open ? portal(h(Stage, { workflow: open, roster, stageLeft, drawer, tab, focusedRunId, onFocusRun: setFocusedRunId, diff --git a/plugins/workflow/test/client.test.js b/plugins/workflow/test/client.test.js index d5449a941..ef83c171a 100644 --- a/plugins/workflow/test/client.test.js +++ b/plugins/workflow/test/client.test.js @@ -1383,3 +1383,70 @@ test('the settings page injects the workflow stylesheet without the sidebar sect globalThis.document = prev } }) + +// The workflow stage polls the store while a run is live, and a read that fails must +// not be painted as "no workflows" (#1564). +test('an active run polls the store once per tick, not twice', async (t) => { + const graph = delivery() + graph.activeCount = 1 + const reads = [] + const call = async (name) => { + if (name === 'workflow/state') { reads.push(Date.now()); return { workflows: [graph] } } + return { ok: true, workflows: [graph] } + } + const timers = [] + const realSet = globalThis.setInterval + const realClear = globalThis.clearInterval + globalThis.setInterval = (fn, ms) => { timers.push({ fn, ms }); return timers.length } + globalThis.clearInterval = () => {} + try { + const view = mount(client.Section, { roster: { bots: [] }, call, actions: {} }) + t.after(() => view.unmount()) + await settle() + assert.equal(reads.length, 1, 'the first paint reads the store once') + assert.equal(timers.length, 1, `exactly one poll is armed for one live run, saw ${timers.length}`) + assert.equal(timers[0].ms, 2000) + timers[0].fn() + await settle() + assert.equal(reads.length, 2, 'one tick is one read') + } finally { + globalThis.setInterval = realSet + globalThis.clearInterval = realClear + } +}) + +test('a failed store read is reported and retried, not painted as no workflows', async (t) => { + await withUnhandled(async (unhandled) => { + const graph = delivery() + let reads = 0 + const call = async (name) => { + if (name === 'workflow/state') { + reads += 1 + if (reads === 1) throw new Error('The workflow store is unreachable') + return { workflows: [graph] } + } + return { ok: true, workflows: [graph] } + } + const timers = [] + const realSet = globalThis.setInterval + const realClear = globalThis.clearInterval + globalThis.setInterval = (fn, ms) => { timers.push({ fn, ms }); return timers.length } + globalThis.clearInterval = () => {} + try { + const view = mount(client.Section, { roster: { bots: [] }, call, actions: {} }) + t.after(() => view.unmount()) + await settle() + assert.deepEqual(unhandled, [], 'a refused read is not an unhandled rejection') + assert.deepEqual(alertsOf(view), ['The workflow store is unreachable'], 'the failure is on screen') + assert.equal(htmlOf(view.tree).includes('No workflows yet.'), false, 'a failure is not an empty shelf') + assert.equal(timers.length, 1, 'the failed read arms a retry') + timers[0].fn() + await settle() + assert.deepEqual(alertsOf(view), [], 'the retry that succeeds clears the alert') + assert.equal(textOf(view.tree).includes('发布交付流水线'), true, 'the workflows are on screen') + } finally { + globalThis.setInterval = realSet + globalThis.clearInterval = realClear + } + }) +})