Repository navigation
Workflow engine step 4: the running layer — a job instantiates its node cards, the queue hands each by role, current inputs open the next #1316
Description
Written-by: Triage
Part of #1272 — step 4, its own issue. One child issue per step; the PR for this step carries Fixes #<this issue>, never Fixes #1272.
Owner: Dev3(2026-10-27T04:00Z 改派:Dev4 两次卡死/被停、零进展——一直没打 agent-working、没有分支、没有 PR;切片落在 core/ 面,Dev3 接手,分支 dev3/1316-workflow-run)
The gap, in one paragraph
#1272 answers one question: may this node open, now, on these facts? (workflow.js enterNode :461, inputStates :361, renderRefusal :318; facts from workflow-facts.js, step 2). Nothing answers the question after it: a job arrives, and nothing turns it into the node cards, nothing hands node n to the queue by node.role, and nothing opens node n+1 when n's outputs become current. Today that layer is a person: the lead reads the table, writes one card by hand, @-s the Bot, and when the PR is PASSed writes the next card. The definition is data, the guard is code, the runtime is still a habit — which is exactly the thing #1272 exists to remove.
So this is the running layer, and only that:
job (one issue card) → node cards (its subtasks) → queue (by node.role, #1247)
→ outputs current → next node's card, in the definition's order
It is not a second engine and not a second board. It uses enterNode to answer "may this open", renderRefusal to say why not, the existing task record to hold a card, and the existing pull queue to hand it out.
What already exists (read before designing; do not build a third of any of these)
core/src/shared/workflow.js— nodes/edges/artifacts,topologicalOrder:183,enterNode:461 (returns ok + the refusing input),renderRefusal:318,workflowColumns:209. Pure: no clock, no fs, no network.core/src/host/workflow-facts.js— one function per fact (FACT_NAMES:16),unreadable():26; a fact that could not be read never becomes a guess.core/src/host/tasks.js— the card.parentId:282, child completion rolls the parent up :322-332,matchChild:375; the queue:unclaimed():418, role match :421-422,requeue():425,claimQueued():505.core/src/host/task-drive.js—DRIVING:20, backoff :12,driveOnce():223,consider():349. This is the only beat that wakes a Bot; the runtime gets no beat of its own.- A pull queue: the lead enqueues work, an idle member of the role claims it #1247 (closed, shipped) — the pull queue: enqueue with a role, a single-winner claim, WIP=1.
- One board: the delivery board and the task board are one board, and a repository task is its issue #1265 (open) — one board: the repo card is the issue card, and the work record hangs under it. Step 3 of One board: the delivery board and the task board are one board, and a repository task is its issue #1265 is what makes the parent a real card; until it lands, part (a) below is testable against a fixture card and part (b) waits.
- The workflow engine: each node names its Bot and its inputs and outputs, the nodes are drawn as a flowchart, and a node missing an input is refused on the card #1272's fixed promises still hold here: no model in the guard path, hold refuses every node, history append-only, the client never writes GitHub.
What this issue pins
- One job, one card, node cards as its children. A node is a
taskwithparentId= the job card, not a second GitHub issue and not a new card schema (that would be a second board). The card gainsworkflowandnode(already in The workflow engine: each node names its Bot and its inputs and outputs, the nodes are drawn as a flowchart, and a node missing an input is refused on the card #1272's model); the child carriesnodeIdand the artifacts it has produced, nothing more. - A node card exists only when the node may open. Creation calls
enterNodewith live facts; when it returns not-ok, no card is created and the reason is the definition's own sentence viarenderRefusal— never a new sentence invented by the runtime. - The hand-off is the queue, not a mention. Enqueue with
role: node.roleand no assignee; the claimer is the assignee the card shows. No@, no direct wake, no second dispatch path —driveOnceis the only beat. - Idempotent and resumable.
(jobId, nodeId)has at most one open card; a restart, a replayed tick or a re-read of the same facts creates no second card. The node history (The workflow engine: each node names its Bot and its inputs and outputs, the nodes are drawn as a flowchart, and a node missing an input is refused on the card #1272 promise 4) is the record, so "did I already open this one" is answered from state, not from a process-local flag. - Opening the next node is a fact, not a follow-up action. n+1 opens when every artifact on its incoming edges is present and current (
artifact.jsfreshness); a stale one refuses in the producer's words (e.g.评审 的输入 check:tests-ok@abc1234 已过期:tip 现在是 def5678). If two nodes are ready at once the definition said so — open both; WIP=1 per Bot is still the queue's rule (A pull queue: the lead enqueues work, an idle member of the role claims it #1247), not a second limit here. - A job is done when its terminal node's outputs exist — not when someone says so, and never by claiming a merge GitHub did not perform.
- Hold refuses every node of every workflow, including one a maintainer Bot asks for; the runtime refuses to create a card for a held job and says who holds it.
- No clock, no timer, no scheduler. Evaluation happens on the
driveOncetick (and on the board write that produced a fact). Nothing polls GitHub from the client; reads go through the host, writes through the task record and itsauthzallow-list (TASK_ENDPOINTS).
Acceptance (each with its own test; the knives are the point)
- A job instantiates: one fixture job on
repo-devproduces exactly one node card,parentId= the job card,node = triage, queued forTriage, no assignee. (Knife: delete theenterNodegate → a job whoseissue.readyis missing must then wrongly produce a card, and test 2 fails.) - A missing or stale input creates nothing, and the refusal text equals
renderRefusal's output for that node/fact pair (byte-equal, asserted). - Hand-off by role: the queued item's
queueRoleisnode.role; a Bot of another role getsnot-in-roleon claim (the existing A pull queue: the lead enqueues work, an idle member of the role claims it #1247 refusal, not a new one). - Idempotency: two
instantiatepasses on the same facts → one card. Restart the host between the passes; still one. (Knife: remove the(jobId,nodeId)guard → test 4 red.) - Next node waits: with
branch/prnot current, node 2 does not open, and the reason names the missing artifact and its revision; when the fact flips current, the next tick opens exactly node 2. - Stale revision refuses: an old
verdict@shaagainst a moved tip opens nothing and says both revisions; the card that is already open is untouched. - Hold: a held job creates no card on any node, in any workflow; a maintainer Bot's attempt refuses too.
- Unreadable fact: no card, and the card/job says the fact could not be read (no pass on a guess, Delivery view fails whole when one request fails: a 404 reads as no GitHub connection, with no retry and no per-endpoint diagnosis #1211).
- Append-only: re-running the tick appends history entries and rewrites no earlier one; a definition edit does not rewrite a card's past nodes.
- No second dispatch: with the runtime enabled,
@-free, a queued node card wakes its role exactly once per item (assert the wake list, not the text); no timer exists to assert because none is added. node --testfor the touched suites plusnpm run verifygreen;node scripts/ds-bot-patches.mjs checkgreen with the new files mirrored underpatches/ds-bot/.
Sequencing (each slice reviewable alone)
- (a) Pure planner —
core/src/shared/workflow-run.js: given a definition (or preset id), the job card's state and a facts map, return the nodes that may open and the refusal for the ones that may not. No fs, no clock, no network, no host imports. Tests drive it with a fixture facts map. This is the part that can start today. - (b) Host writer —
core/src/host/workflow-run.js, called fromtask-drive.jsdriveOnceand after a task write: creates/queues the card through the existing record, keyed(jobId, nodeId); waits for One board: the delivery board and the task board are one board, and a repository task is its issue #1265 step 3 so the parent is the real issue card. - (c) Closure — when a node's card is done and its declared outputs are current, append the node's completion and open the next. No write to GitHub, no label change, no merge.
Out of scope
- No second board, no second card schema, no new client surface (the card already renders
workflow/node; step 3 shipped it). - No scheduler, no cron, no "wake a Bot because time passed" — waking stays A pull queue: the lead enqueues work, an idle member of the role claims it #1247's claim on
driveOnce. - No GitHub writes from the client, no ruleset change, no model in the path.
- Not parallel execution of one workflow beyond what its edges declare; not retries/compensation beyond the queue's
requeue(a failed node card is blocked and says why). - Not the runtime's job to judge a PR or merge one: it asks the facts, it does not become a gate.
Activity
feat(workflow): the running layer's pure half — a job instantiates it…
Birfy commentedon Oct 27, 2026
Written-by: Dev4
Claimed #1316 — branch
dev4/1272-step4, base694dfe32(main after #1312).First step: the pure planner
core/src/shared/workflow-run.js—planJobreturns the node cards a job may open (each decisionenterNode's, refusal copied byte for byte) andjobDone/completionsanswer the closure half; no fs, clock, timer, network or host import. Scope is slice (a) + (c)'s pure half only; the host writer waits for #1265 step 3.Birfy commentedon Oct 27, 2026
Written-by: Dev3
Claimed — branch
dev3/1316-workflow-run, cut from694dfe32.First step: read
workflow.js'senterNode/renderRefusal/artifact.jsfreshness and therepo-devpreset, then write slice (a) —core/src/shared/workflow-run.js, a pure planner (definition + job card + facts map in, the nodes that may open plus the refusal for each one that may not), driven by a fixture facts map with no fs, clock, network or host import. (b) and (c) will be named as not-this-issue in the PR.feat(workflow): the running layer's pure half — a job instantiates it…
feat(workflow): the running layer's pure half — a job instantiates it…
workflow-run: planJob header agrees with the existing-card skip (Thin…