Skip to content

Commit 6181504

Browse files
committed
fix(workflow): settle a run's node map when the run is ended (#1424)
A run ended by reap() at load, or by settle()'s dispatch catch, was marked failed while its nodeStatus still said running for the node the host was on. publicRun() derives `running` from nodeStatus, so the flow strip kept painting "Running now" for a run that had ended and was not going to move again. Both paths now end a run through one helper: the nodes still running become failed, the run gets one failed line in its history, and updatedAt moves. A run that already ended keeps its own status and history. A node the graph no longer has is left as it is rather than thrown, and reap() ends runs one by one so one bad record cannot leave the rest running.
1 parent 26657c8 commit 6181504

4 files changed

Lines changed: 202 additions & 14 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
A run ended at load, or by a step the host refused, kept the nodes it had been running: the run said `failed` while the flow strip still painted "Running now" for that node. Ending a run now marks those nodes `failed` and records one `failed` line in the run's history ([#1424](https://github.com/ThinkFlowLab/FlyBot/issues/1424)).

‎docs/subsystems/workflow.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ Everything in `plugins/workflow/`: the graph rules, the run store, the matcher,
4949
3. Progress lines stay scripted, and a run does not create a schedule. `scheduleLabel` is display text. When the host can create Bots, starting a run (`assign`, `run_workflow`, `ingest`, or a demo that starts or restarts) spawns one ephemeral Bot per node from that node's prompt, role, model and tools, sends it the step from the Main Bot, and removes every instance when the run finishes, fails for good, or is reset. A `failOnce` retry keeps them. They are hidden and left out of the sidebar. Loading the plugin does not spawn Bots for the seeded fixture run.
5050

5151
Loading is also the one moment a run the host left behind is noticed: a run still marked `running` is abandoned, so loading releases its instances and records the run as `failed`. Nothing resumes it. A run that already ended keeps its own status (#1388).
52+
53+
Ending a run settles its node map with it: every node still marked `running` becomes `failed`, and the run gets one `failed` line in its history. `publicRun().running` is derived from `nodeStatus`, so a run that says `failed` while a node still says `running` is a ghost the strip paints as "Running now". `settle()`'s dispatch catch ends the run the same way, through the same helper (#1424).
5254
4. Auto-assign considers an `open` card only. First hit wins: a `workflow:` token, then `workflowId` / `workflow`, then an assignee that names a workflow, then a `bot:` token, then an assignee that names a local Bot, then the longest trigger contained in the title, note or criteria. A miss returns no route. A second ingest of the same task and target is a no-op, and a live run for that task id is not restarted.
5355
5. The browser draws text only. Names and messages are React text children. The unit renderer asserts a hostile name is not markup.
5456
6. `enabled: false` registers no endpoint, tool or listener and writes nothing.
@@ -57,4 +59,4 @@ Everything in `plugins/workflow/`: the graph rules, the run store, the matcher,
5759

5860
## Tests
5961

60-
`plugins/workflow/test/`: graph advance and join, graph edit, assign and persistence, templates, `define` / `runNamed`, the matcher, ephemeral spawn and cleanup, the client canvas, the settings page, and a sidebar click that leaves the Workflows section in place. Run `npm test` in `plugins/workflow`. The host seam that applies `ownerBotId` is `core/test/task-route.test.js`. The host omission of run Bots is `core/test/flybot.test.js`.
62+
`plugins/workflow/test/`: graph advance and join, graph edit, assign and persistence, templates, `define` / `runNamed`, the matcher, ephemeral spawn and cleanup, a reaped run's settled node map (#1424), the client canvas, the settings page, and a sidebar click that leaves the Workflows section in place. Run `npm test` in `plugins/workflow`. The host seam that applies `ownerBotId` is `core/test/task-route.test.js`. The host omission of run Bots is `core/test/flybot.test.js`.

‎plugins/workflow/src/service.js‎

Lines changed: 38 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,32 @@ export function createWorkflowService({ dir, persist = true, now = () => Date.no
226226
if (wrote) write()
227227
}
228228

229+
// End a run the host gave up on. Both paths that can abandon one — reap() at load
230+
// and settle()'s catch — come through here, so run.status and run.nodeStatus
231+
// cannot drift apart and a client stops painting "Running now" (#1424).
232+
function endRun(item, run) {
233+
// A run that already ended keeps its own ending (#1388); only its records go.
234+
if (run.status === 'done' || run.status === 'failed') {
235+
delete run.ephemeral
236+
delete run.handed
237+
return
238+
}
239+
for (const nodeId of item ? orderedIds(item, run.nodeStatus, 'running') : []) {
240+
// The graph can be edited under a live run: a node it no longer has, or one
241+
// that moved on already, is left as it is rather than thrown (#1424).
242+
try {
243+
run.nodeStatus = failNode(item, run.nodeStatus, nodeId).status
244+
} catch (error) {
245+
warn(`flybot-workflow: node ${nodeId} kept its status: ${error?.message ?? error}`)
246+
}
247+
}
248+
run.status = 'failed'
249+
run.updatedAt = now()
250+
run.history = [...(run.history ?? []), { task: run.task, status: 'failed', at: now() }].slice(-20)
251+
delete run.ephemeral
252+
delete run.handed
253+
}
254+
229255
async function settle(workflowId) {
230256
const item = workflow(workflowId)
231257
const run = item ? state.runs[workflowId] : undefined
@@ -234,17 +260,13 @@ export function createWorkflowService({ dir, persist = true, now = () => Date.no
234260
await handOff(item, run)
235261
} catch (error) {
236262
await releaseAll(Object.values(run.ephemeral ?? {}), release, warn)
237-
delete run.ephemeral
238-
delete run.handed
239-
run.status = 'failed'
240-
run.updatedAt = now()
263+
endRun(item, run)
241264
write()
242265
return refuse(error)
243266
}
244267
if (run.status === 'done' || run.status === 'failed') {
245268
await releaseAll(Object.values(run.ephemeral ?? {}), release, warn)
246-
delete run.ephemeral
247-
delete run.handed
269+
endRun(item, run)
248270
write()
249271
}
250272
return present()
@@ -551,13 +573,16 @@ export function createWorkflowService({ dir, persist = true, now = () => Date.no
551573
// and nothing resumes it: its instances are abandoned, not in progress.
552574
// Release them and end the run, or they stay hidden with no way to remove
553575
// them. A run that already ended keeps its own status (#1388).
554-
const abandoned = run.status === 'running'
555-
stale.push(...Object.values(run.ephemeral))
556-
delete run.ephemeral
557-
delete run.handed
558-
if (abandoned) {
559-
run.status = 'failed'
560-
run.updatedAt = now()
576+
try {
577+
stale.push(...Object.values(run.ephemeral))
578+
if (run.status === 'running') endRun(workflow(run.workflowId), run)
579+
else {
580+
delete run.ephemeral
581+
delete run.handed
582+
}
583+
} catch (error) {
584+
// One run that cannot be ended must not leave the others running.
585+
warn(`flybot-workflow: could not end ${run.id}: ${error?.message ?? error}`)
561586
}
562587
}
563588
if (stale.length === 0) return Promise.resolve()

‎plugins/workflow/test/ephemeral.test.js‎

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,3 +388,163 @@ test('the plugin load itself reaps a run the restart left running (#1388)', asyn
388388
rmSync(dir, { recursive: true, force: true })
389389
}
390390
})
391+
392+
// #1424: ending a run has to settle the node map with it. `publicRun` exposes
393+
// `running` from `nodeStatus`, and the strip paints "Running now" from that, so a
394+
// run that says `failed` while a node still says `running` is a ghost on screen.
395+
test('a load settles the node map: no node stays running and the run records one failure (#1424)', async () => {
396+
const dir = mkdtempSync(join(tmpdir(), 'wf-reap-1424-'))
397+
const released = []
398+
let clock = 1_700_000_000_000
399+
try {
400+
const before = persistent(dir, released, () => clock)
401+
const made = before.define({
402+
name: 'Lane',
403+
nodes: [
404+
{ id: 'plan', name: 'Planner', role: 'plan', prompt: 'Write the plan.' },
405+
{ id: 'code', name: 'Coder', role: 'code', prompt: 'Implement it.' },
406+
],
407+
edges: [{ from: 'plan', to: 'code' }],
408+
}).workflows.find(item => item.name === 'Lane')
409+
const started = await before.runNamed({ workflow: 'Lane', brief: 'Ship the note' })
410+
assert.equal(started.ok, true)
411+
const saved = readStore(dir).runs[made.id]
412+
assert.equal(saved.nodeStatus.plan, 'running')
413+
414+
clock += 60_000
415+
const after = persistent(dir, released, () => clock)
416+
await after.reap()
417+
const run = after.state().workflows.find(item => item.id === made.id).run
418+
assert.equal(run.status, 'failed')
419+
assert.deepEqual(run.running, [])
420+
assert.equal(run.nodeStatus.plan, 'failed')
421+
assert.equal(run.nodeStatus.code, 'queued')
422+
assert.equal(run.doneCount, 0)
423+
assert.equal(run.history.length, 1)
424+
assert.equal(run.history[0].status, 'failed')
425+
assert.equal(run.history[0].at, clock)
426+
assert.equal(run.history[0].task.id, saved.task.id)
427+
428+
// The store, not just the view, carries the settled map.
429+
const settled = readStore(dir).runs[made.id]
430+
assert.equal(settled.nodeStatus.plan, 'failed')
431+
assert.equal(settled.history.length, 1)
432+
433+
// A second load has nothing to release and appends no second line.
434+
released.length = 0
435+
await persistent(dir, released, () => clock).reap()
436+
assert.deepEqual(released, [])
437+
assert.equal(readStore(dir).runs[made.id].history.length, 1)
438+
} finally {
439+
rmSync(dir, { recursive: true, force: true })
440+
}
441+
})
442+
443+
test('a run that already ended keeps its ending and its history when reap drops its records (#1424)', async () => {
444+
const dir = mkdtempSync(join(tmpdir(), 'wf-reap-1424-done-'))
445+
const released = []
446+
const clock = () => 1_700_000_000_000
447+
try {
448+
const svc = persistent(dir, released, clock)
449+
const made = svc.define({
450+
name: 'Lane',
451+
nodes: [{ id: 'only', name: 'Planner', role: 'plan', prompt: 'Say it once.' }],
452+
edges: [],
453+
}).workflows.find(item => item.name === 'Lane')
454+
const store = readStore(dir)
455+
store.runs[made.id] = {
456+
id: 'run-done',
457+
workflowId: made.id,
458+
task: null,
459+
status: 'done',
460+
nodeStatus: { only: 'done' },
461+
steps: 1,
462+
stepBudget: 2,
463+
failedOnce: {},
464+
history: [{ task: null, status: 'done', at: 5 }],
465+
startedAt: 4,
466+
updatedAt: 6,
467+
ephemeral: { only: 'eph-only' },
468+
}
469+
writeFileSync(join(dir, 'workflows.json'), JSON.stringify(store))
470+
471+
const after = persistent(dir, released, clock)
472+
await after.reap()
473+
assert.deepEqual(released, ['eph-only'])
474+
const run = after.state().workflows.find(item => item.id === made.id).run
475+
assert.equal(run.status, 'done')
476+
assert.equal(run.updatedAt, 6)
477+
assert.equal(run.nodeStatus.only, 'done')
478+
assert.equal(run.history.length, 1)
479+
assert.equal(run.history[0].status, 'done')
480+
assert.equal(run.ephemeral, undefined)
481+
} finally {
482+
rmSync(dir, { recursive: true, force: true })
483+
}
484+
})
485+
486+
test('a run whose node map names a node the graph no longer has settles the rest and does not throw (#1424)', async () => {
487+
const dir = mkdtempSync(join(tmpdir(), 'wf-reap-1424-ghost-'))
488+
const released = []
489+
const clock = () => 1_700_000_000_000
490+
try {
491+
const svc = persistent(dir, released, clock)
492+
const made = svc.define({
493+
name: 'Lane',
494+
nodes: [{ id: 'plan', name: 'Planner', role: 'plan', prompt: 'Write the plan.' }],
495+
edges: [],
496+
}).workflows.find(item => item.name === 'Lane')
497+
const store = readStore(dir)
498+
store.runs[made.id] = {
499+
id: 'run-ghost',
500+
workflowId: made.id,
501+
task: null,
502+
status: 'running',
503+
nodeStatus: { plan: 'running', gone: 'running' },
504+
steps: 0,
505+
stepBudget: 2,
506+
failedOnce: {},
507+
history: [],
508+
startedAt: 4,
509+
updatedAt: 5,
510+
ephemeral: { plan: 'eph-plan', gone: 'eph-gone' },
511+
}
512+
writeFileSync(join(dir, 'workflows.json'), JSON.stringify(store))
513+
514+
const after = persistent(dir, released, clock)
515+
await after.reap()
516+
assert.deepEqual(released.sort(), ['eph-gone', 'eph-plan'])
517+
const run = after.state().workflows.find(item => item.id === made.id).run
518+
assert.equal(run.status, 'failed')
519+
assert.equal(run.nodeStatus.plan, 'failed')
520+
// Nothing to move: the graph no longer has that node, so it keeps its record.
521+
assert.equal(run.nodeStatus.gone, 'running')
522+
assert.deepEqual(run.running, [])
523+
assert.equal(run.history.length, 1)
524+
assert.equal(run.history[0].status, 'failed')
525+
assert.equal(run.ephemeral, undefined)
526+
} finally {
527+
rmSync(dir, { recursive: true, force: true })
528+
}
529+
})
530+
531+
test('a dispatch that throws ends the run the same way: nodes settled and one failure line (#1424)', async () => {
532+
const dispatched = []
533+
const { svc, spawned, released } = harness({
534+
service: {
535+
dispatch: async (step) => { dispatched.push(step); throw new Error('dispatch blew up') },
536+
},
537+
})
538+
const made = lane(svc).workflows.find(item => item.name === 'Lane')
539+
const result = await svc.runNamed({ workflow: 'Lane', brief: 'Ship the note' })
540+
assert.equal(result.ok, false)
541+
assert.equal(dispatched.length, 1)
542+
const run = svc.state().workflows.find(item => item.id === made.id).run
543+
assert.equal(run.status, 'failed')
544+
assert.deepEqual(run.running, [])
545+
assert.equal(run.nodeStatus.plan, 'failed')
546+
assert.equal(run.nodeStatus.code, 'queued')
547+
assert.equal(run.history.length, 1)
548+
assert.equal(run.history[0].status, 'failed')
549+
assert.deepEqual(released.sort(), spawned.map(entry => entry.id).sort())
550+
})

0 commit comments

Comments
 (0)