From bae974bc3b03cceb7c401b512ed6922944fb29a6 Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Tue, 27 Oct 2026 17:35:03 +0800 Subject: [PATCH] Do not mark a workflow step as handed when dispatch refuses it handOff() ignored the dispatch answer and recorded every running node as handed, so with no Main Bot (or no bot.send) the run sat in `running` with its nodes marked handed and nothing running. Read the answer: on `ok: false` leave the node unmarked, log one line with the code, and let the next settle try again. Fixes #1389 Docs-not-needed: plugin-internal fix; no architecture, subsystem ownership or user-facing change. --- plugins/workflow/src/service.js | 8 +++++- plugins/workflow/test/ephemeral.test.js | 36 +++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/plugins/workflow/src/service.js b/plugins/workflow/src/service.js index bfeb8acbb..f781d017b 100644 --- a/plugins/workflow/src/service.js +++ b/plugins/workflow/src/service.js @@ -203,7 +203,7 @@ export function createWorkflowService({ dir, persist = true, now = () => Date.no const node = nodeById(item, nodeId) const botId = run.ephemeral[nodeId] if (!botId || !node) continue - await dispatch({ + const answer = await dispatch({ botId, nodeId, name: node.name, @@ -214,6 +214,12 @@ export function createWorkflowService({ dir, persist = true, now = () => Date.no brief: run.task?.title ?? '', taskId: run.task?.id ?? '', }) + // A refused step is not handed over: leave it unmarked so the next settle + // tries again, and say why in one line — otherwise the run stalls silently. + if (answer?.ok === false) { + warn(`flybot-workflow: step ${nodeId} was not handed over: ${answer.code ?? 'dispatch refused'}`) + continue + } run.handed = { ...run.handed, [nodeId]: true } wrote = true } diff --git a/plugins/workflow/test/ephemeral.test.js b/plugins/workflow/test/ephemeral.test.js index 027336bce..15a600643 100644 --- a/plugins/workflow/test/ephemeral.test.js +++ b/plugins/workflow/test/ephemeral.test.js @@ -187,3 +187,39 @@ test('apply wires spawn to a hidden ephemeral create and removes those Bots when assert.equal(done.workflows.find(item => item.id === made.id).run.status, 'done') assert.deepEqual(removed, ['bot-1']) }) + +test('#1389: a step the host refuses is not marked as handed, and a later settle sends it again', async () => { + let answer = { ok: false, code: 'no-sender' } + const calls = [] + const warnings = [] + const { svc } = harness({ + service: { + dispatch: async (step) => { calls.push(step); return answer }, + warn: (...args) => warnings.push(args.join(' ')), + }, + }) + const made = svc.define({ + name: 'Pair', + nodes: [ + { id: 'plan', name: 'Planner', role: 'plan', prompt: 'Write the plan.' }, + { id: 'code', name: 'Coder', role: 'code', prompt: 'Implement it.' }, + ], + edges: [], + }).workflows.find(item => item.name === 'Pair') + const started = await svc.runNamed({ workflow: 'Pair', brief: 'Ship the note' }) + assert.equal(started.ok, true) + const run = started.workflows.find(item => item.id === made.id).run + assert.equal(run.status, 'running') + assert.deepEqual(calls.map(step => step.nodeId).sort(), ['code', 'plan']) + + // A refused step is not welded shut: the next settle sends it again, to the same Bot. + answer = { ok: true } + const moved = await svc.advance({ workflowId: made.id, nodeId: 'plan', outcome: 'done' }) + assert.equal(moved.ok, true) + const after = moved.workflows.find(item => item.id === made.id).run + assert.equal(after.nodeStatus.code, 'running') + const codeCalls = calls.filter(step => step.nodeId === 'code') + assert.equal(codeCalls.length, 2) + assert.equal(codeCalls[0].botId, codeCalls[1].botId) + assert.match(warnings.join('\n'), /no-sender/) +})