Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/1564-stage-poll.fixed.md
Original file line number Diff line number Diff line change
@@ -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))
32 changes: 13 additions & 19 deletions plugins/workflow/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) },
Expand All @@ -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,
Expand Down
67 changes: 67 additions & 0 deletions plugins/workflow/test/client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
})
})
Loading