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/1668.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A card re-matched from one workflow into another now leaves one line in the source graph's log naming the card and its destination, so the hand-over is visible where the card disappeared ([#1668](https://github.com/ThinkFlowLab/FlyBot/issues/1668)).
4 changes: 2 additions & 2 deletions docs/subsystems/workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ Everything in `plugins/workflow/`: the graph rules, the run store, the matcher,
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. A declared output a gate reads is required (§3): when a `when`/`else` or `until` edge reads it and the step's reply does not produce it, the step fails with `failure.text` `Missing OUTPUT <names>` instead of settling, so the gate never reads silence as "not this branch" and the run cannot close `done` on it; a delivered value that does not equal `eq` still completes the step and skips the untaken branch (#1773). 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`.
7. A loop edge (`loop: true`, `max` from 1 to 8, default 3, optional `until: { output, eq }`) is the only cycle for success paths. The body runs again until `until` matches or `max` runs have finished, and the forward edges wait until then. Restarting the body drops the branch choice made inside it and queues a sibling that the previous pass skipped, so the next pass can take the other side. A `when: { output, eq }` edge is taken only when that output equals `eq`. `when: { else: true }` is taken only when no other `when` matched. A `when` or `until` that is present but cannot be honoured — a blank output name, or one outside `/^[a-z][a-z0-9-]{0,40}$/` such as `result_ok` — refuses the save with `A condition link needs an output name like "ready"` (or the loop's own line) instead of being dropped: before #2155 the refused `when` was indistinguishable from an absent one and the edge fired unconditionally, which is the opposite of what the author asked. An absent `when`/`until` is still a plain link, no early stop. Settings → Workflow graphs keeps **Add link** disabled for a Condition with a blank output name, and the refusal shows in the page's `.wf-err` alert. A node whose forward edges are all closed is `skipped` and does not block a join. A run cannot spin: every loop has a max. An `onFail` edge (`onFail: true`, `max` recoveries from 1 to 8, default 3) provides failure recovery: when a node fails, if an `onFail` edge points from that node back to an upstream target and recoveries remain (tracked in `failAttempts` per edge), the workflow resets every node from the recovery target through the failing node (the target to `running`, the rest to `queued`) and re-runs them in order. After `max` recoveries are used, the node fails permanently and the run stops. An `onFail` edge does not interfere with `failOnce` retry (which restarts the same node once) or success-path `loop` edges. Settings → Workflow graphs can add a link, a condition, or a loop; a failure recovery edge is added through `define_workflow` (`onFail: true`) and has no Settings control yet.
8. Auto-assign considers an `open` card only. Every saved board write reaches the hook, including a card a Bot records for itself through `track_tasks`, so a `workflow:<id>` on the card a Bot wrote starts that run (#1462). A saved Bot claim beats a workflow trigger. Otherwise 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 in the title, note, criteria or tags — an ASCII trigger must match a whole word (`notice` does not match `noticeable`, `pr` does not match `Improve`), while a non-ASCII trigger matches inside a longer text because CJK has no word boundary. 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. A stored claim yields only to the card's own named target — a `workflow:`/`bot:` token, `workflowId`/`workflow`, or an assignee naming a workflow or local Bot — and only when that target is live and different: the claim and its route go, the card is matched again, and exactly one of each remains. A trigger, or a card that names nothing, never invalidates a claim; a claim naming a workflow or Bot that no longer exists keeps today's behaviour, because this invalidates on named intent and does not guess. `heldTasks`/`heldTaskCount` on `workflow/state` are that claim map read back per workflow, and `rematch: true` is the person's explicit drop for one task (#1647).
8. Auto-assign considers an `open` card only. Every saved board write reaches the hook, including a card a Bot records for itself through `track_tasks`, so a `workflow:<id>` on the card a Bot wrote starts that run (#1462). A saved Bot claim beats a workflow trigger. Otherwise 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 in the title, note, criteria or tags — an ASCII trigger must match a whole word (`notice` does not match `noticeable`, `pr` does not match `Improve`), while a non-ASCII trigger matches inside a longer text because CJK has no word boundary. 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. A stored claim yields only to the card's own named target — a `workflow:`/`bot:` token, `workflowId`/`workflow`, or an assignee naming a workflow or local Bot — and only when that target is live and different: the claim and its route go, the card is matched again, and exactly one of each remains. The source workflow's log gains exactly one system line naming the card and its destination (`Task re-matched · {task} → {to}`), written in the same mutation as the new claim and never by a write of its own, so a person watching the graph reads where the card went instead of seeing it vanish (#1668). A trigger, or a card that names nothing, never invalidates a claim; a claim naming a workflow or Bot that no longer exists keeps today's behaviour, because this invalidates on named intent and does not guess. `heldTasks`/`heldTaskCount` on `workflow/state` are that claim map read back per workflow, and `rematch: true` is the person's explicit drop for one task (#1647).
9. The browser draws text only. Names and messages are React text children. The unit renderer asserts a hostile name is not markup.
10. `enabled: false` registers no endpoint, tool or listener and writes nothing.
11. A sidebar click opens the stage or a dialog and leaves the Workflows section in place. The stage and the dialogs portal onto `document.body`. When that container is missing they stay in the section tree; the click does not throw.
Expand Down Expand Up @@ -121,7 +121,7 @@ conclusion about the pull request.

## Tests

`plugins/workflow/test/`: graph advance and join, graph edit, assign and persistence, the FIFO queue and concurrency cap, Bot-reply advance, a dispatch that fails without being marked handed, a bounded hand-off retry and a step deadline that fails the run and clears its timer (#1530), a per-workflow `stepTimeoutMinutes` that replaces the ten-minute constant and is refused outside 1..120 (#1580), disposing releases the Bots before the record drops their ids and a released runner name is reusable again, assign-to-bot versus assign-to-workflow, inputs and outputs along edges, a loop that breaks and an if/else branch, failure recovery (onFail edges with max attempts, exhaustion behavior, and compatibility with failOnce and loop edges), templates, `define` / `runNamed`, the matcher, ephemeral spawn and cleanup, a stopped run with its settled node map (#1424), the reply middleware, the client canvas, the settings page (including a condition and a loop), rename and delete (including factory-id rows such as `delivery`), the stage chat's scroll rule (a new visible line follows, a filtered node reply moves nothing), the assign dialog's preselected card and its failed and empty board states (#1457, #1458), a sidebar click that leaves the Workflows section in place, and the claim rules — a re-routed card moves with one claim and one route, the same target writes nothing, a trigger never invalidates, the held list is capped and omits a Bot claim, the held page answering `heldLimit` up to its 200 cap and the stage unfolding it behind its `wf-more` row (#2256), `rematch: true` drops one claim, and the panel's one re-match action goes through `ingest` (#1647), and the assigned brief reaching the step prompt while a task without one still gets its title, a parked card keeping its brief on disk, and an unknown `assign` field coming back in `ignored` (#1869). Run `npm test` in `plugins/workflow`. The host seam that applies `ownerBotId` is `core/test/task-route.test.js`, which also pins that a card written through `track_tasks` reaches the listener (#1462). The host omission of run Bots, and the grant of `stepTools` plus the hidden-approval answer, is `core/test/flybot.test.js`. Which connectors a run Bot may call is `core/test/connector-access.test.js`, and the chain from a node's `tools` through `spawn` to that guard — the built-in delivery nodes included — is `core/test/workflow-node-connectors.test.js`.
`plugins/workflow/test/`: graph advance and join, graph edit, assign and persistence, the FIFO queue and concurrency cap, Bot-reply advance, a dispatch that fails without being marked handed, a bounded hand-off retry and a step deadline that fails the run and clears its timer (#1530), a per-workflow `stepTimeoutMinutes` that replaces the ten-minute constant and is refused outside 1..120 (#1580), disposing releases the Bots before the record drops their ids and a released runner name is reusable again, assign-to-bot versus assign-to-workflow, inputs and outputs along edges, a loop that breaks and an if/else branch, failure recovery (onFail edges with max attempts, exhaustion behavior, and compatibility with failOnce and loop edges), templates, `define` / `runNamed`, the matcher, ephemeral spawn and cleanup, a stopped run with its settled node map (#1424), the reply middleware, the client canvas, the settings page (including a condition and a loop), rename and delete (including factory-id rows such as `delivery`), the stage chat's scroll rule (a new visible line follows, a filtered node reply moves nothing), the assign dialog's preselected card and its failed and empty board states (#1457, #1458), a sidebar click that leaves the Workflows section in place, and the claim rules — a re-routed card moves with one claim and one route, the same target writes nothing, a trigger never invalidates, the held list is capped and omits a Bot claim, the held page answering `heldLimit` up to its 200 cap and the stage unfolding it behind its `wf-more` row (#2256), `rematch: true` drops one claim, and the panel's one re-match action goes through `ingest` (#1647), and a re-match leaves one notice line in the source log while the target side and the untouched paths stay byte-for-byte the same (#1668), and the assigned brief reaching the step prompt while a task without one still gets its title, a parked card keeping its brief on disk, and an unknown `assign` field coming back in `ignored` (#1869). Run `npm test` in `plugins/workflow`. The host seam that applies `ownerBotId` is `core/test/task-route.test.js`, which also pins that a card written through `track_tasks` reaches the listener (#1462). The host omission of run Bots, and the grant of `stepTools` plus the hidden-approval answer, is `core/test/flybot.test.js`. Which connectors a run Bot may call is `core/test/connector-access.test.js`, and the chain from a node's `tools` through `spawn` to that guard — the built-in delivery nodes included — is `core/test/workflow-node-connectors.test.js`.

## Connector ceiling (#1544)

Expand Down
16 changes: 16 additions & 0 deletions plugins/workflow/src/service.js
Original file line number Diff line number Diff line change
Expand Up @@ -246,10 +246,10 @@
}

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

Check warning on line 249 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 252 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()]) {
Expand Down Expand Up @@ -1815,6 +1815,21 @@
const key = `${decision.kind}:${decision.id}`
const prev = state.routes[taskId]
if (prev?.key === key) return { ok: true, matched: true, already: true, decision }
// #1668: a card leaving one workflow for another leaves a line in the source graph, so a
// person watching it reads where the card went instead of seeing it vanish. Written in the
// same mutation as the target's claim below, never by a write() of its own.
const reroutedFrom = held?.kind === 'workflow' && decision.kind === 'workflow' && held.id !== decision.id ? held.id : undefined
const noteReroute = () => {
if (reroutedFrom === undefined) return
const taskLabel = summarizeTask(task)
const to = workflow(decision.id)?.name ?? decision.id
push(reroutedFrom, [{
kind: 'system',
key: 'Task re-matched · {task} → {to}',
params: { task: taskLabel, to },
text: `任务已改投 · ${taskLabel} → ${to}`,
}], now())
}
if (decision.kind === 'bot') {
rememberClaim(taskId, decision)
write()
Expand All @@ -1831,6 +1846,7 @@
const finish = (value) => {
if (value?.ok === false) return value
rememberClaim(taskId, decision)
noteReroute()
write()
return { ...present(), matched: true, already: false, decision }
}
Expand Down
Loading
Loading