Skip to content
Closed
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/1549.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Deleting a running node from the workflow canvas no longer strands its run: the edit ends that run the way Stop does — the node is failed, the run is archived `failed`, its node Bots are released and a queued task takes the lane. Before, the run stayed `running` with a queued-only node map and no timer, so under `maxConcurrent: 1` nothing could ever start behind it. ([#1549](https://github.com/ThinkFlowLab/FlyBot/issues/1549))
2 changes: 1 addition & 1 deletion docs/subsystems/workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ Everything in `plugins/workflow/`: the graph rules, the run store, the matcher,

1. Friends' Bots are not nodes. A `remote` Bot is refused by create, define and the matcher.
2. The canvas and the run share one graph: nodes, edges and positions. A drag commits `workflow/graph`, but only while **Edit** is on — a factory preset is copied first (#1378). Status highlighting reads that same graph. Replacing edges does not recompute who is already running. The Settings canvas paints that graph on the same 960px design plane, scaled to the width the settings page gives it, so the canvas is not a second scroll container inside the page's own scroll; its pointer deltas are divided by the scale, and its wires keep a stronger stroke than a plain divider so an edge can be followed at that size (#1483).
3. **A live run advances only when its ephemeral Bot replies, following the graph's edges in order.** That reply advances the run and may appear on the workflow stage log; it is not delivered into a persistent sidebar Bot conversation (for example Chief). The `workflow-step` send middleware denies those replies after `stepResult` runs. Nodes start only when their forward predecessors finish and edges reach them. **Early termination (#1537, #1538):** When a node completes **or fails** with `needs-human`, `already-claimed`, `tip-unchanged`, or `open-pr` detected in its outputs or reply text (see `checkEarlyEnd` in Key files), the run ends immediately with status `done`, sets `run.earlyEnd` to the condition name, and does not advance to successors, take a success-path `loop`, or burn `onFail` recovery attempts. Preferred signals are a declared OUTPUT equal to the marker, or a whole reply line `EARLY-END <reason>`; the engine also honors common phrases (`tip unchanged` / `unchanged tip`, `has open PR` / `already has open PR` / `found open PR`, plus the existing already-claimed heuristics) so a Bot that forgets the structured marker cannot soft-lock Claim↔Verify on the same tip. Factory prompt guidance lives in `EARLY_END_GUIDANCE` (`plugins/workflow/src/template.js`) for Settings graphs that wire Triage / Claim / Verify: Verify must escalate once on an unchanged tip, not re-run the full suite. There is no scripted or seeded run path: `workflow/state` exposes only `live === true` runs. A run does not create a schedule. `scheduleLabel` is display text. When the host can create Bots, starting a live run (`assign`, `run_workflow`, `ingest`) 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 default workflows. Plugin unload (`dispose`) and the load-time `reap` both end a live run still marked `running`: in-flight nodes are failed, the run is archived as `failed`, and any ephemeral Bot ids are released. A run already written as `running` with no ephemeral map (an older unload) is healed the same way on the next load (#1414). Loading is also the one moment a run the host left behind is noticed when its ephemeral map is still present: `reap` releases those instances and records the run as `failed`. Nothing resumes it. A run that already ended keeps its own status (#1388). `workflow/reset` stopping one live run ends it the same way before its Bots are released: nodes still `running` become `failed`, so the strip cannot keep painting a run that is over (#1424). Every wait is bounded (#1530). A dispatch that does not land is not marked handed: the chat names the code and the attempt, and it is retried after a short backoff at most twice, never twice inside one hand-off, so a 429 is not hammered. When it still does not land, the step and the run fail with the reason written into the chat. Each running live step also holds a deadline — `STEP_DEADLINE_MS`, ten minutes, one constant in `service.js` (armed even when the host cannot spawn ephemeral Bots); when it expires the node and the run fail as `failed`, the run's ephemeral Bots are released, the chat says the step timed out, and `warn` names it once. The deadline is cleared as soon as the step moves, and by Pause, Stop, Reset, delete, `reap` and `dispose`; a paused run holds none, and Resume arms it again from that moment. A timer never outlives the run that started it. A reply that matches no running step — its run ended, or the node already moved — is reported through `warn` once per Bot and run instead of being dropped silently. A reply that says `could not answer your message` or `model call failed` fails that node and does not start its successor. A 429 or rate-limit mention in any other reply does not.
3. **A live run advances only when its ephemeral Bot replies, following the graph's edges in order.** That reply advances the run and may appear on the workflow stage log; it is not delivered into a persistent sidebar Bot conversation (for example Chief). The `workflow-step` send middleware denies those replies after `stepResult` runs. Nodes start only when their forward predecessors finish and edges reach them. **Early termination (#1537, #1538):** When a node completes **or fails** with `needs-human`, `already-claimed`, `tip-unchanged`, or `open-pr` detected in its outputs or reply text (see `checkEarlyEnd` in Key files), the run ends immediately with status `done`, sets `run.earlyEnd` to the condition name, and does not advance to successors, take a success-path `loop`, or burn `onFail` recovery attempts. Preferred signals are a declared OUTPUT equal to the marker, or a whole reply line `EARLY-END <reason>`; the engine also honors common phrases (`tip unchanged` / `unchanged tip`, `has open PR` / `already has open PR` / `found open PR`, plus the existing already-claimed heuristics) so a Bot that forgets the structured marker cannot soft-lock Claim↔Verify on the same tip. Factory prompt guidance lives in `EARLY_END_GUIDANCE` (`plugins/workflow/src/template.js`) for Settings graphs that wire Triage / Claim / Verify: Verify must escalate once on an unchanged tip, not re-run the full suite. There is no scripted or seeded run path: `workflow/state` exposes only `live === true` runs. A run does not create a schedule. `scheduleLabel` is display text. When the host can create Bots, starting a live run (`assign`, `run_workflow`, `ingest`) 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 default workflows. Plugin unload (`dispose`) and the load-time `reap` both end a live run still marked `running`: in-flight nodes are failed, the run is archived as `failed`, and any ephemeral Bot ids are released. A run already written as `running` with no ephemeral map (an older unload) is healed the same way on the next load (#1414). Loading is also the one moment a run the host left behind is noticed when its ephemeral map is still present: `reap` releases those instances and records the run as `failed`. Nothing resumes it. A run that already ended keeps its own status (#1388). `workflow/reset` stopping one live run ends it the same way before its Bots are released: nodes still `running` become `failed`, so the strip cannot keep painting a run that is over (#1424). A graph edit that drops a node that is still running ends that run the way Stop does: the node is failed, the run is archived `failed`, its node Bots are released, and the queue drains (#1549). Every wait is bounded (#1530). A dispatch that does not land is not marked handed: the chat names the code and the attempt, and it is retried after a short backoff at most twice, never twice inside one hand-off, so a 429 is not hammered. When it still does not land, the step and the run fail with the reason written into the chat. Each running live step also holds a deadline — `STEP_DEADLINE_MS`, ten minutes, one constant in `service.js` (armed even when the host cannot spawn ephemeral Bots); when it expires the node and the run fail as `failed`, the run's ephemeral Bots are released, the chat says the step timed out, and `warn` names it once. The deadline is cleared as soon as the step moves, and by Pause, Stop, Reset, delete, `reap` and `dispose`; a paused run holds none, and Resume arms it again from that moment. A timer never outlives the run that started it. A reply that matches no running step — its run ended, or the node already moved — is reported through `warn` once per Bot and run instead of being dropped silently. A reply that says `could not answer your message` or `model call failed` fails that node and does not start its successor. A 429 or rate-limit mention in any other reply does not.
4. Each workflow keeps a FIFO queue and up to `maxConcurrent` live runs (default 2, an integer from 1 to 8 on the record, via `workflow/concurrency`, or in Settings → Workflow graphs). A task that does not fit is enqueued. When a run finishes, every workflow with a free slot is drained, oldest enqueue first. A new assign on a workflow that already has a queue waits behind it. A task does not replace a running run or release that run's Bots. Migrating an old store keeps only runs that were still `running` with ephemeral Bot ids; other legacy scripted rows are dropped from active state (finished tasks may remain in lane history). History records a finished or failed live run. Lowering the cap does not stop a run that has already started. Assigning `{ botId }` claims the card for that local Bot and does not enqueue. A remote Bot is refused. `activeCount` is `runningOf(lane)` — live runs with `live === true` and `status === 'running'`. The stage aggregate chip and the Runs tab (运行记录) must agree: every such run appears as its own running row and progress card, and the chip count does not exceed that list.
5. A node declares up to eight inputs and eight outputs. A required input is an output of the same name on an ancestor, or `task`, `title`, or `brief` on a root. A start with no provider is refused, and Bots spawned for that attempt are released. On a Bot-backed run a successor stays queued until its required inputs are present. If every forward predecessor is done, failed or skipped and an input is still missing, that node fails and the run stops. The dispatch text carries `Input <name>: <value>`. The Bot reports `OUTPUT <name>: <value>`; a single declared output may be the reply body. Nodes start in edge order.
6. Each node is a real ephemeral Bot for that run, then it is removed. A permanent sidebar Bot (`botId`) is optional and is not required for the step to run. **File and shell rights are the same as a normal sidebar Bot's:** every node Bot gets the `danger-full-access` preset, and `EPHEMERAL_DENIED_TOOLS` still refuses the team-admin tools at the guard and approval layers (#1466, 29 names). **`node.tools` selects connectors only:** `spawn` passes the node's cleaned list to `fb.bot.create`, the host records it as `stepTools`, and `connector-access.js` allows `mcp__github__*` for `github` and `mcp__composio__*` for `composio` — a connector the node did not name is refused, and a node that names none reaches none. The list is also stored in `fields.tools` for the UI. A hidden run Bot cannot show an approval card, so the approval seam answers the same way the guard does. The host stores this on the run Bot as `stepTools` and `permission`.
Expand Down
79 changes: 62 additions & 17 deletions plugins/workflow/src/service.js
Original file line number Diff line number Diff line change
Expand Up @@ -151,13 +151,13 @@
}

function clearRunTimers(runId) {
for (const [key, entry] of [...timers]) {

Check warning on line 154 in plugins/workflow/src/service.js

View workflow job for this annotation

GitHub Actions / plugins (workflow)

unicorn(no-useless-spread)

Using a spread operator here creates a new array unnecessarily.
if (entry.runId === runId) clearTimerAt(key)
}
for (const key of [...warned]) {

Check warning on line 157 in plugins/workflow/src/service.js

View workflow job for this annotation

GitHub Actions / plugins (workflow)

unicorn(no-useless-spread)

Using a spread operator here creates a new array unnecessarily.
if (key.endsWith(`:${runId}`)) warned.delete(key)
}
for (const key of [...handOffTries.keys()]) {

Check warning on line 160 in plugins/workflow/src/service.js

View workflow job for this annotation

GitHub Actions / plugins (workflow)

unicorn(no-useless-spread)

Using a spread operator here creates a new array unnecessarily.
if (key.startsWith(`${runId}:`)) handOffTries.delete(key)
}
}
Expand Down Expand Up @@ -188,7 +188,7 @@
// advance(); without this the step could sit unanswered forever (#1530).
if (run?.live !== true) return
const running = new Set(orderedIds(item, run.nodeStatus, 'running'))
for (const [key, entry] of [...timers]) {

Check warning on line 191 in plugins/workflow/src/service.js

View workflow job for this annotation

GitHub Actions / plugins (workflow)

unicorn(no-useless-spread)

Using a spread operator here creates a new array unnecessarily.
if (entry.runId !== run.id) continue
if (run.status !== 'running' || run.paused === true || !running.has(entry.nodeId)) clearTimerAt(key)
}
Expand Down Expand Up @@ -1112,8 +1112,12 @@
},
// Replace the graph the canvas and the run share. Positions and edges
// persist; a node that remains keeps its run status, a new one waits.
// A node dropped while it is running takes its run with it: the run is
// ended the way Stop/Reset/dispose end one, so nothing is left `running`
// with no node to advance it and no way to release the lane (#1549).
saveGraph(body = {}) {
return mutate(String(body.workflowId ?? ''), (item) => {
const endedRuns = []
const saved = mutate(String(body.workflowId ?? ''), (item) => {
// #1544: a save from a Bot records it as the graph's author; a save with no Bot behind it (the
// canvas, the API, a template copy) is the owner's own edit and lifts the ceiling again.
const authoredBy = cleanLine(body.callerBotId ?? body.authoredBy, 80) || undefined
Expand All @@ -1132,20 +1136,56 @@
edges: body.edges,
layers: undefined,
})
item.nodes = next.nodes
item.edges = next.edges
item.layers = next.layers
const lane = laneOf(item.id)
const run = displayRun(item.id)
if (run?.live === true) {
const ids = new Set(item.nodes.map(node => node.id))
const nodeStatus = {}
for (const id of ids) nodeStatus[id] = run.nodeStatus?.[id] ?? 'queued'
const nextRun = { ...run, nodeStatus, updatedAt: now() }
if (lane.active.some(itemRun => itemRun.id === run.id)) storeRun(item.id, nextRun)
const liveRuns = lane.active.filter(itemRun => itemRun.live === true)
if (lane.last?.live === true && !liveRuns.some(itemRun => itemRun.id === lane.last.id)) liveRuns.push(lane.last)
const ids = new Set(next.nodes.map(node => node.id))
for (const run of liveRuns) {
// endRun settles the node map against the graph it ran on, so it must
// see the old nodes; the next graph is applied after this loop.
const dropped = orderedIds(item, run.nodeStatus, 'running').filter(id => !ids.has(id))
const names = dropped.map(id => nodeById(item, id)?.name ?? id)
const ended = dropped.length > 0
const settled = ended ? endRun(item, run) : run
// A run that continues settles against the new graph, so a node this edit
// dropped leaves its map. A run this edit ended keeps its own map — the
// failed node included — the way reset() and dispose() keep theirs (#1424).
let nodeStatus = settled.nodeStatus ?? {}
if (!ended) {
nodeStatus = {}
for (const id of ids) nodeStatus[id] = settled.nodeStatus?.[id] ?? 'queued'
}
const nextRun = { ...settled, nodeStatus, updatedAt: now() }
if (ended) endedRuns.push({ run: nextRun, names })
else if (lane.active.some(itemRun => itemRun.id === run.id)) storeRun(item.id, nextRun)
else if (lane.last?.id === run.id) lane.last = nextRun
syncTimers(item, nextRun)
}
item.nodes = next.nodes
item.edges = next.edges
item.layers = next.layers
for (const { run, names } of endedRuns) {
lane.active = lane.active.filter(itemRun => itemRun.id !== run.id)
lane.last = run
lane.history = [...lane.history, { task: run.task, status: run.status, at: run.updatedAt }].slice(-20)
push(item.id, [{ kind: 'system', tone: 'bad', text: `■ 已停止 · ${summarizeTask(run.task)}(图编辑删除了正在运行的节点:${names.join('、')})` }], now())
}
})
if (saved.ok === false || endedRuns.length === 0) return saved
const releasing = endedRuns.flatMap(({ run }) => Object.values(run.ephemeral ?? {}))
for (const { run } of endedRuns) {
delete run.ephemeral
delete run.handed
}
write()
if (typeof spawn !== 'function') {
drainSync()
return present()
}
return hostSerial(async () => {
await releaseAll(releasing, release, warn)
await drainAll()
return present()
})
},
// A Bot (or the owner) writes a graph. A name that already exists is replaced.
Expand All @@ -1155,14 +1195,19 @@
const authoredBy = cleanLine(body.callerBotId, 80) || undefined
const existing = body.workflow ? findWorkflow(body.workflow) : undefined
if (existing) {
const finish = () => {
const name = cleanLine(body.name, 80)
if (name) {
existing.name = name
write()
}
return present()
}
const saved = this.saveGraph({ workflowId: existing.id, nodes: body.nodes, edges: body.edges ?? [], ...(authoredBy ? { authoredBy } : {}) })
// saveGraph returns a promise when the edit had to end a live run.
if (typeof saved?.then === 'function') return saved.then(value => (value?.ok === false ? value : finish()))
if (saved.ok === false) return saved
const name = cleanLine(body.name, 80)
if (name) {
existing.name = name
write()
}
return present()
return finish()
}
return this.create({ name: body.name, nodes: body.nodes, edges: body.edges ?? [], description: body.description, ...(authoredBy ? { authoredBy } : {}) })
},
Expand Down
Loading
Loading