Repository navigation
feat: Cloud 侧工作流调度推进器(specs#99 落地第 3 步) - #67
Conversation
ora-space#67 D2 puts the agent node's configuration inside the publish gate: the first phase executes `executorSlot`, `roleId` and `prompt`, and defers `skills`, `mcps`, `interactive`, `outputContract`, `retry` and `promptInactivity`. A field no runner reads has to be refused by name rather than ignored, because a publish that succeeded while dropping it silently reads to its author as "my setting is in effect" — the one outcome the decision rules out. `validateAgentConfig` runs for agent nodes whose kind Cloud already executes, so a `tool` node carrying `skills` is reported once, for the kind: its configuration is not the author's to fix while the node itself has to go. A key written as null is not refused — these fields are refused because they change execution behavior, and a null changes none. Each problem carries `field` beside `nodeId`, and the list is walked in its declared order so the editor renders a stable order instead of the document's map order. Saving is untouched: an unfinished draft still saves, which the same decision requires and the integration case exercises. Evidence: the unit matrix gains a case per field plus all six at once, and TestWorkflowPublishRefusesUnusedAgentConfiguration is the real-HTTP half the core test case asks for — the publish is 400 naming both fields with no snapshot frozen, while the same document without them publishes.
Zpc001
left a comment
There was a problem hiding this comment.
Request changes
调度器本身我逐段读过:认领用 CAS(UPDATE ... WHERE id=$1 AND status='pending')、只从已提交的 node_states 重算就绪集、落状态再走版本 CAS、agent 节点在第一期诚实地把运行判失败而不是假装成功,这些都对。两处必须先改,其中一处会让 Cloud 崩。
1. 两条 CI 门禁挂在这份 head 上
用仓库自己的门禁在本地复现(go1.27.1):
$ go run ./cmd/checkformat # backend.yml 的同一条,跑 go tool gofumpt -l -extra .
internal/core/workflow_scheduler.go
internal/core/workflow_scheduler_test.go
exit status 1
$ go tool golangci-lint run ./internal/core/...
internal/core/workflow_scheduler.go:123:22: Error return value is not checked (errcheck)
for _, raw := range graph["nodes"].([]any) {
internal/core/workflow_scheduler.go:143:22: Error return value is not checked (errcheck)
for _, raw := range graph["edges"].([]any) {
internal/core/workflow_scheduler.go:91:1: File is not properly formatted (gofumpt)
for _, id := range ids {
internal/core/workflow_scheduler.go:148:14: unnecessary conversion (unconvert)
internal/core/workflow_condition.go:169:14: unnecessary conversion (unconvert)
internal/core/workflow_condition.go:205:13: unnecessary conversion (unconvert)
main 本身是 gofmt 干净的,所以这是本分支引入的;GitHub 上 check 也已经是 fail。workflow_scheduler.go:91 那个多缩进一层是明显的笔误,unconvert 三处是参数已经是 Object/map[string]any 可直接用(asWorkflowObject 的返回值)。
2. readWorkflowGraph 的裸类型断言会 panic,并且会把进程带走
for _, raw := range graph["nodes"].([]any) { // workflow_scheduler.go:123
for _, raw := range graph["edges"].([]any) { // workflow_scheduler.go:143图里没有 nodes/edges 数组时这是 nil 接口断言,直接 runtime panic。这不是我推理出来的路径:graphText 在没有 graph 字段时存的是 {},而发布门禁是 df507f0(#65)才有的,比它更早发布的快照可以是任意形状——正是函数注释里说要"给出诚实的运行而不是 panic"的那一批。main 自己处理同一件事全部走 comma-ok,可以照着改:
internal/core/workflow_graph.go:113nodes, _ := graph["nodes"].([]any)internal/core/workflow_graph.go:124nodes, ok := graph["nodes"].([]any)internal/core/workflow_graph.go:211edges, _ := graph["edges"].([]any)
后果为什么是致命的:internal/core/store.go 的 transact 只把 *Fault 和 databaseFailure 转成 error,其余 default: panic(r) 原样抛出;cmd/server/main.go:213 这个循环走的是 pluginmarket.RunSyncLoop,它没有 recover:
for {
if e := sync(ctx); e != nil && !errors.Is(e, context.Canceled) {
log.Warn(...)
}
...
}goroutine 里未被 recover 的 panic 会终止整个 Cloud 进程。而且 scanAdvancableWorkflowRuns 是 ORDER BY created_at, id,那一行每次扫描都会被最先取出,重启后第一轮就再 panic 一次——不是"这一轮跳过这个运行",是起不来。
3. 记下来的运行失败原因不确定
workflowRunFailureReason 遍历的是 map[string]workflowNodeState,多个节点失败时返回哪一个取决于 map 迭代顺序,写进运行的 reason 就成了随机值,同一份 trace 两次重放可能不一样。按图的节点顺序找第一个失败节点即可。
4. 合并前先说一句
这份 head 基于更早的 main。我把它和 #65(df507f0)合过,单独合本 PR 是通的、go build ./... 过;三个 PR 一起合才会出现重名(validateWorkflowSlots 与 rejectWith 与 main 的声明冲突),那是第 3 步那个 PR 要处理的,这里只提醒按顺序合并、每次合并后重新 build。
Refs ora-space/circus#141
🤖 Generated with Claude Code
fd285a1 to
efb7066
Compare
The first phase executes a DAG and nothing else (ora-space#67 结论 4, D1 条件④), but nothing enforced it. A cyclic document published fine, and a run of one settled `succeeded`: a cycle reachable from the start went round once and stopped with its back edge silently dropped, and a cycle nothing reached left its nodes unresolved with no state at all — the run reported success for a document whose author drew a loop, and for nodes that produced no result (invariant 5). Both layers are closed by one walk, so a graph Cloud refuses to publish and a graph Cloud refuses to run are the same graph: - `workflowGraphCycle` (workflow_scheduler.go) is a three-colour depth-first walk over the same reader the run uses. It names the source of the back edge it found, which is on a cycle by construction — not a node merely downstream of one, and not a node between two cycles, which is where a peel-based detector lands. - The publish gate refuses with `graph_cycle` naming that node. - The advance pass settles such a run `failed` with `graph has a cycle` before the first node runs, so a snapshot frozen before the gate existed neither executes as a DAG it is not nor settles succeeded. The no-start refusal beside the new one now finishes through `finishWorkflowRun` like every other terminal settle, so a terminal run never holds live resources (D7) even when it is a run that was already in flight when the check landed. Regression evidence — new tests against the pre-fix implementation: - `TestValidateWorkflowGraph`: `problems = []`, want `[graph_cycle]` for a self-loop, a cycle behind the start, a cycle with nodes downstream of it, and a node between two cycles. - `TestWorkflowAdvanceFailsACyclicSnapshot` and `TestWorkflowAdvanceFailsAHistoricalCycleBehindTheStart`: `run settled "succeeded" error ""`, want `failed/graph has a cycle`. Co-Authored-By: Claude Code <noreply@anthropic.com>
Connect WorkflowRun to the first real execution step on the Cloud side (specs decisions/cloud/workflow 0-cloud-side-scheduler-owns-workflow-execution, landing order step 3, circus#70; run issue circus#141). A bounded retry-loop pass (limit 100, ordered created_at,id) claims pending workflow_runs and advances running ones, each in its own short transaction. The ready set is recomputed from committed state only: the frozen snapshot graph plus the node_states column, resolved to fixed point exactly like desktop's BranchProjection (unlabeled edge = else, condition edges matched by the committed selectedBranch, rootless non-start nodes inactive, pending upstream leaves downstream unresolved). Node results commit behind the run's version CAS, so a late or duplicate pass is a no-op and a replica that crashes mid-pass resumes from committed state. start/condition/output execute inline: start reports the run input, condition evaluates its cases against prior node outputs (desktop condition.rs operator set, first matching case wins, else fallback) and commits the selected branch, output marks the terminal node. An agent node is the phase-1 boundary: until the execution wiring lands it settles the run failed with reason agent_execution_not_wired - the honest terminal state, never a hang in running and never a success for work that did not happen. The loop is owned by cmd/server's process lifecycle like the other ten-second recovery loops. Tests cover the projection and condition semantics as pure core, plus DB tests on isolated schemas for claim+settle, the agent boundary and idempotence. Co-Authored-By: Claude Code <noreply@anthropic.com>
efb7066 to
12a3fe4
Compare
The first phase executes a DAG and nothing else (ora-space#67 结论 4, D1 条件④), but nothing enforced it. A cyclic document published fine, and a run of one settled `succeeded`: a cycle reachable from the start went round once and stopped with its back edge silently dropped, and a cycle nothing reached left its nodes unresolved with no state at all — the run reported success for a document whose author drew a loop, and for nodes that produced no result (invariant 5). Both layers are closed by one walk, so a graph Cloud refuses to publish and a graph Cloud refuses to run are the same graph: - `workflowGraphCycle` (workflow_scheduler.go) is a three-colour depth-first walk over the same reader the run uses. It names the source of the back edge it found, which is on a cycle by construction — not a node merely downstream of one, and not a node between two cycles, which is where a peel-based detector lands. - The publish gate refuses with `graph_cycle` naming that node. - The advance pass settles such a run `failed` with `graph has a cycle` before the first node runs, so a snapshot frozen before the gate existed neither executes as a DAG it is not nor settles succeeded. The no-start refusal beside the new one now finishes through `finishWorkflowRun` like every other terminal settle, so a terminal run never holds live resources (D7) even when it is a run that was already in flight when the check landed. Regression evidence — new tests against the pre-fix implementation: - `TestValidateWorkflowGraph`: `problems = []`, want `[graph_cycle]` for a self-loop, a cycle behind the start, a cycle with nodes downstream of it, and a node between two cycles. - `TestWorkflowAdvanceFailsACyclicSnapshot` and `TestWorkflowAdvanceFailsAHistoricalCycleBehindTheStart`: `run settled "succeeded" error ""`, want `failed/graph has a cycle`. Co-Authored-By: Claude Code <noreply@anthropic.com>
`workflowRunFailureReason` read the reason out of the committed state map. Go randomizes map iteration, so a run with more than one failed node recorded whichever failure the iteration happened to reach first: the same trace could record a different reason on every replay, which makes the field useless as evidence of anything. Walk the graph's nodes instead. Every committed state belongs to a graph node — the scheduler writes them from graph nodes and the graph is frozen at creation — so node order reaches them all and is stable across replays. The regression test asserts the first-in-order reason across 200 calls: against the old implementation it failed within the first 15 iterations, so the determinism is pinned rather than assumed. Co-Authored-By: Claude Code <noreply@anthropic.com>
The first phase executes a DAG and nothing else (ora-space#67 结论 4, D1 条件④), but nothing enforced it. A cyclic document published fine, and a run of one settled `succeeded`: a cycle reachable from the start went round once and stopped with its back edge silently dropped, and a cycle nothing reached left its nodes unresolved with no state at all — the run reported success for a document whose author drew a loop, and for nodes that produced no result (invariant 5). Both layers are closed by one walk, so a graph Cloud refuses to publish and a graph Cloud refuses to run are the same graph: - `workflowGraphCycle` (workflow_scheduler.go) is a three-colour depth-first walk over the same reader the run uses. It names the source of the back edge it found, which is on a cycle by construction — not a node merely downstream of one, and not a node between two cycles, which is where a peel-based detector lands. - The publish gate refuses with `graph_cycle` naming that node. - The advance pass settles such a run `failed` with `graph has a cycle` before the first node runs, so a snapshot frozen before the gate existed neither executes as a DAG it is not nor settles succeeded. The no-start refusal beside the new one now finishes through `finishWorkflowRun` like every other terminal settle, so a terminal run never holds live resources (D7) even when it is a run that was already in flight when the check landed. Regression evidence — new tests against the pre-fix implementation: - `TestValidateWorkflowGraph`: `problems = []`, want `[graph_cycle]` for a self-loop, a cycle behind the start, a cycle with nodes downstream of it, and a node between two cycles. - `TestWorkflowAdvanceFailsACyclicSnapshot` and `TestWorkflowAdvanceFailsAHistoricalCycleBehindTheStart`: `run settled "succeeded" error ""`, want `failed/graph has a cycle`. Co-Authored-By: Claude Code <noreply@anthropic.com>
Zpc001
left a comment
There was a problem hiding this comment.
复审结论:Request changes
复审对象:head 0d9363d。已在真实 PostgreSQL(127.0.0.1:55432)下执行 go build ./...、go run ./cmd/checkformat、go tool golangci-lint run ./internal/core/... ./cmd/...、go vet,以及 go test -race ./internal/core -run 'TestWorkflow(Advance|Projection|Condition|RunFailureReason)',全部通过。
上一轮的 4 条都已确认修复:
- 格式与 lint 干净。
readWorkflowGraph已改为 comma-ok(workflow_scheduler.go:125,145)。- 失败原因按
graph.Nodes顺序遍历,200 次循环用例能稳定拦住旧实现。 - 与 main 的堆叠关系正确,构建无重复符号。
阻塞项(P2,均在生产可达)
1. empty / not_empty 对命名类型 Object 判断错误 — internal/core/workflow_condition.go:81-97
类型 switch 写的是 case map[string]any:,而 core.Object 是 type Object map[string]any,动态类型不匹配,于是落到 default: return false。start 节点的输出是 Object{"input": run.O("input")},run.O("input") 本身也是 Object;selector [startId, "input"] 解析出的就是 Object。失败场景:运行输入为 {} 的合法图(start → condition),empty 得到 false、not_empty 得到 true,与 desktop is_empty 的结论相反,分支走错。请用 asWorkflowObject(resolved) 后再判空,并补一条输入为 {} 的用例。
2. not_contains 在变量 absent 时返回 true,违反文件自己写下的规则 — workflow_condition.go:30,105-111
文件头(15-19 行)明确“comparator on an absent variable does not match”,not_equals / is_not 也显式 absent→false;但 not_contains 是 negate(contains),absent→!false = true。同一变量上 not_contains 会选中分支,not_equals 落到 else。desktop 对未赋值变量直接报 UnsetVariable。请统一:absent 时返回 false(或改注释并说明这是有意偏离 desktop)。
3. 条件语义与 desktop 参考实现不一致,且发布门禁不校验 condition 配置
文件头声明与 condition.rs parity,但当前实现有可见差异:
contains:Cloud 把两侧序列化成 canonical JSON 做子串匹配(workflowTextOf);desktop 对 array 是元素相等、对 object/number/bool 返回TypeMismatch。例:resolved["foobar"]、expected"foo",Cloud true、desktop false;object 上 Cloud 可能 true、desktop 报错。starts_with/ends_with:desktop 要求两侧都是 string;Cloud 把非字符串序列化后比较(number12starts_with1为 true,desktop 报TypeMismatch)。- 期望值中的
{{#selector#}}模板:desktop 会解析并保持绑定类型,Cloud 按字面串比较。 - 编译期校验:desktop 拒绝缺 case id、未知
logic、未知 operator、单段 selector;Cloud 的发布门禁validateWorkflowGraph完全不看 condition 配置,这些图能发布,运行时把未知 logic 当and、缺 id 当 else、单段 selector 当整个 output。ADR 落地第 2 步要求“含首期不支持节点或配置的图被拒绝且原因可定位”,这一条现在没有做到。
请二选一:按 desktop 语义补齐并在发布门禁拒绝不支持的配置;或把偏离写进 ADR 并获得 owner 批准,不要只改注释。当前状态是“声明 parity、实际不一致”,后续对拍会直接失败。
4. ADR 状态与核心用例证据未同步
PR 描述没有说明它实现的 decisions/cloud/workflow/0-cloud-side-scheduler-owns-workflow-execution.md 保持 approved 还是随实现转 implemented,也没有对应的 specs PR;test-cases/cloud/workflow/scheduler-serialism-and-recovery.md 仍有 16 处 Missing。该 ADR 的条件③要求对拍用例与批准同时就位,cloud/AGENTS.md 也要求行为变更同步 core-test 证据。请补 PR 描述的 ADR 状态行,并在合并前提交 specs PR。
非阻塞说明
- 在途判定:当前
workflow_scheduler.go:399-432在 ready 为空时直接结案,没有has_in_flight检查。本 PR 内 agent 节点直接失败,不可达;#72 已经在advanceWorkflowRun加了 running 节点的先行结算与提前返回,确认不会提前记 succeeded。 - 环校验:本 PR 没有发布门禁的环检查,#72 已补
validateWorkflowAcyclic与调度器兜底,按合并顺序可接受。 workflow_scheduler.go:91-97用_ = err吞掉每个 run 的 transact 错误,持久化错误不可见,且 scan 错误会被 plugin sync 循环记成“plugin catalog sync failed”,日志误导;建议至少限流记录并返回首个错误。workflow_scheduler.go:57-73的 scan 每 10 秒全表排序;表增长后建议加WHERE status IN ('pending','running')的部分索引。- 测试缺口:畸形 graph(第 2 条旧 finding 的修复)没有用例;
graph.Start==""的失败路径没有用例;没有两 pass 并发认领竞争用例;workflow_scheduler_db_test.go:114的strings.Contains(runErr,"a")恒真(“agent” 含 a),实际只校验了 “not wired”。
…sence
Two Condition-node bugs found in review, both reachable from a legal
`start -> condition` graph:
- `empty` / `not_empty` type-switched on `map[string]any`, which a named `Object`
never matches. A start node's output nests the run input as an `Object`, so a
selector walking `[startId, "input"]` resolved to an `Object` and fell through
to the default arm — `empty` answered false on `{}` and the run took the branch
desktop's is_empty would not have taken, with nothing in the run to say so. The
object arm now goes through `asWorkflowObject`, the helper the rest of the
document reading already uses for exactly this trap.
- `not_contains` was `negate(contains)`, so it answered true on an absent
variable while every other comparator in the file — `not_equals` included —
answers false. The file's own rule is that a comparator on an absent variable
does not match; the negation belongs to the comparison, not to the
classification of absence. `not_contains` gets its own operator that keeps
absence a no-match.
Both are pinned end to end by new DB tests driving a real
start -> condition -> {case, else} graph through the advance pass: an empty run
input must select the empty case, `not_empty` on the same input must not, and
`not_contains` must not select on a path no node produced.
Co-Authored-By: Claude Code <noreply@anthropic.com>
…of it Review round on ora-space#67, non-blocking item. `strings.Contains(runErr, "a")` cannot fail: the node it claims to look for is named "a", so every non-empty string satisfies it, and the check that actually did work was the one for "not wired". The test therefore asserted less than its message said. The unwired-agent path is also the one failure that writes a run error of its own — `agent node <id> is not wired to execution yet` — rather than reusing the node's reason the way workflowRunFailureReason does everywhere else, so naming the node is a property worth pinning. It is pinned as the whole sentence now. Co-Authored-By: Claude Code <noreply@anthropic.com>
…ike desktop
The publish gate validated condition nodes only for the fields every node
shares, so a document naming an operator Cloud does not implement, a logic
neither side defines, a case with no id, a one-segment selector or an expected
value carrying `{{#...#}}` published cleanly and then answered something at run
time that nothing in the document asked for — a no-match, an `and`, the else
branch, the whole node output, a literal comparison. desktop's condition.rs
answers differently in each of those places, so the graph was accepted under
one semantics and evaluated under another.
This is the gate half of circus#202's owner decision A condition 2: the list
of configurations the first phase does not support now lives in
workflowUnsupportedConditionConfigs, publishing refuses each with a problem
that names the node and the field, and the test checks the list against the
cases in both directions so the ADR's list, the PR's list and the gate cannot
drift apart.
An unfinished node is deliberately not refused: no cases, or a case with no
rules, falls through to else on both sides already. The differences that only
show up on a *resolved* value — `contains` on an array or object, a non-string
`starts_with`/`ends_with`, an expected-value template that resolves — cannot be
decided from the document and stay with the parity work in circus#210.
Co-Authored-By: Claude Code <noreply@anthropic.com>
The first phase executes a DAG and nothing else (ora-space#67 结论 4, D1 条件④), but nothing enforced it. A cyclic document published fine, and a run of one settled `succeeded`: a cycle reachable from the start went round once and stopped with its back edge silently dropped, and a cycle nothing reached left its nodes unresolved with no state at all — the run reported success for a document whose author drew a loop, and for nodes that produced no result (invariant 5). Both layers are closed by one walk, so a graph Cloud refuses to publish and a graph Cloud refuses to run are the same graph: - `workflowGraphCycle` (workflow_scheduler.go) is a three-colour depth-first walk over the same reader the run uses. It names the source of the back edge it found, which is on a cycle by construction — not a node merely downstream of one, and not a node between two cycles, which is where a peel-based detector lands. - The publish gate refuses with `graph_cycle` naming that node. - The advance pass settles such a run `failed` with `graph has a cycle` before the first node runs, so a snapshot frozen before the gate existed neither executes as a DAG it is not nor settles succeeded. The no-start refusal beside the new one now finishes through `finishWorkflowRun` like every other terminal settle, so a terminal run never holds live resources (D7) even when it is a run that was already in flight when the check landed. Regression evidence — new tests against the pre-fix implementation: - `TestValidateWorkflowGraph`: `problems = []`, want `[graph_cycle]` for a self-loop, a cycle behind the start, a cycle with nodes downstream of it, and a node between two cycles. - `TestWorkflowAdvanceFailsACyclicSnapshot` and `TestWorkflowAdvanceFailsAHistoricalCycleBehindTheStart`: `run settled "succeeded" error ""`, want `failed/graph has a cycle`. Co-Authored-By: Claude Code <noreply@anthropic.com>
golangci-lint's misspell check rejected `recognises` and `behaviour` in the new comments, which is the only thing standing between this branch and a green CI run. No code changes. Co-Authored-By: Claude Code <noreply@anthropic.com>
Zpc001
left a comment
There was a problem hiding this comment.
复审 241b294,通过。
核对项:
- 9 个拒绝码与 ADR「condition 节点的当前不支持配置」表逐行一致;
TestWorkflowPublishRefusesUnsupportedConditionConfigs对用例集合与workflowUnsupportedConditionConfigs做双向断言,任一侧新增都会让用例失败。 - 放行侧有反向用例:没有
cases、分支为空、未完成的 case 都不被拒,符合「未完成草稿仍可保存」的边界。 - 每个问题都带
nodeId与field,发布在冻结快照之前拒绝;导入路径落地时调用同一个requireValidWorkflowGraph。 - 未改动 #68/#72 的文件,堆叠范围干净。
- 本地在
REQUIRE_POSTGRES=1下跑过TestWorkflowPublishRefusesUnsupportedConditionConfigs与 workflow 相关用例,通过;CI 绿。 - PR 描述写明 ADR 仍为
approved,符合这是 D3 落地中的一步、不是整个 ADR 完成。
本批准只覆盖 cloud#67。按 circus#202 的 owner 结论,condition 与 desktop 的其余语义差异归 circus#210,门禁里对应的拒绝项在 #210 完成后逐项删掉。#68/#72 当前与 main 冲突,需 rebase 后重新跑 CI 再复审。
…of it Review round on ora-space#67, non-blocking item. `strings.Contains(runErr, "a")` cannot fail: the node it claims to look for is named "a", so every non-empty string satisfies it, and the check that actually did work was the one for "not wired". The test therefore asserted less than its message said. The unwired-agent path is also the one failure that writes a run error of its own — `agent node <id> is not wired to execution yet` — rather than reusing the node's reason the way workflowRunFailureReason does everywhere else, so naming the node is a property worth pinning. It is pinned as the whole sentence now. Co-Authored-By: Claude Code <noreply@anthropic.com>
The first phase executes a DAG and nothing else (ora-space#67 结论 4, D1 条件④), but nothing enforced it. A cyclic document published fine, and a run of one settled `succeeded`: a cycle reachable from the start went round once and stopped with its back edge silently dropped, and a cycle nothing reached left its nodes unresolved with no state at all — the run reported success for a document whose author drew a loop, and for nodes that produced no result (invariant 5). Both layers are closed by one walk, so a graph Cloud refuses to publish and a graph Cloud refuses to run are the same graph: - `workflowGraphCycle` (workflow_scheduler.go) is a three-colour depth-first walk over the same reader the run uses. It names the source of the back edge it found, which is on a cycle by construction — not a node merely downstream of one, and not a node between two cycles, which is where a peel-based detector lands. - The publish gate refuses with `graph_cycle` naming that node. - The advance pass settles such a run `failed` with `graph has a cycle` before the first node runs, so a snapshot frozen before the gate existed neither executes as a DAG it is not nor settles succeeded. The no-start refusal beside the new one now finishes through `finishWorkflowRun` like every other terminal settle, so a terminal run never holds live resources (D7) even when it is a run that was already in flight when the check landed. Regression evidence — new tests against the pre-fix implementation: - `TestValidateWorkflowGraph`: `problems = []`, want `[graph_cycle]` for a self-loop, a cycle behind the start, a cycle with nodes downstream of it, and a node between two cycles. - `TestWorkflowAdvanceFailsACyclicSnapshot` and `TestWorkflowAdvanceFailsAHistoricalCycleBehindTheStart`: `run settled "succeeded" error ""`, want `failed/graph has a cycle`. Co-Authored-By: Claude Code <noreply@anthropic.com>
The first phase executes a DAG and nothing else (ora-space#67 结论 4, D1 条件④), but nothing enforced it. A cyclic document published fine, and a run of one settled `succeeded`: a cycle reachable from the start went round once and stopped with its back edge silently dropped, and a cycle nothing reached left its nodes unresolved with no state at all — the run reported success for a document whose author drew a loop, and for nodes that produced no result (invariant 5). Both layers are closed by one walk, so a graph Cloud refuses to publish and a graph Cloud refuses to run are the same graph: - `workflowGraphCycle` (workflow_scheduler.go) is a three-colour depth-first walk over the same reader the run uses. It names the source of the back edge it found, which is on a cycle by construction — not a node merely downstream of one, and not a node between two cycles, which is where a peel-based detector lands. - The publish gate refuses with `graph_cycle` naming that node. - The advance pass settles such a run `failed` with `graph has a cycle` before the first node runs, so a snapshot frozen before the gate existed neither executes as a DAG it is not nor settles succeeded. The no-start refusal beside the new one now finishes through `finishWorkflowRun` like every other terminal settle, so a terminal run never holds live resources (D7) even when it is a run that was already in flight when the check landed. Regression evidence — new tests against the pre-fix implementation: - `TestValidateWorkflowGraph`: `problems = []`, want `[graph_cycle]` for a self-loop, a cycle behind the start, a cycle with nodes downstream of it, and a node between two cycles. - `TestWorkflowAdvanceFailsACyclicSnapshot` and `TestWorkflowAdvanceFailsAHistoricalCycleBehindTheStart`: `run settled "succeeded" error ""`, want `failed/graph has a cycle`. Co-Authored-By: Claude Code <noreply@anthropic.com>
The first phase executes a DAG and nothing else (ora-space#67 结论 4, D1 条件④), but nothing enforced it. A cyclic document published fine, and a run of one settled `succeeded`: a cycle reachable from the start went round once and stopped with its back edge silently dropped, and a cycle nothing reached left its nodes unresolved with no state at all — the run reported success for a document whose author drew a loop, and for nodes that produced no result (invariant 5). Both layers are closed by one walk, so a graph Cloud refuses to publish and a graph Cloud refuses to run are the same graph: - `workflowGraphCycle` (workflow_scheduler.go) is a three-colour depth-first walk over the same reader the run uses. It names the source of the back edge it found, which is on a cycle by construction — not a node merely downstream of one, and not a node between two cycles, which is where a peel-based detector lands. - The publish gate refuses with `graph_cycle` naming that node. - The advance pass settles such a run `failed` with `graph has a cycle` before the first node runs, so a snapshot frozen before the gate existed neither executes as a DAG it is not nor settles succeeded. The no-start refusal beside the new one now finishes through `finishWorkflowRun` like every other terminal settle, so a terminal run never holds live resources (D7) even when it is a run that was already in flight when the check landed. Regression evidence — new tests against the pre-fix implementation: - `TestValidateWorkflowGraph`: `problems = []`, want `[graph_cycle]` for a self-loop, a cycle behind the start, a cycle with nodes downstream of it, and a node between two cycles. - `TestWorkflowAdvanceFailsACyclicSnapshot` and `TestWorkflowAdvanceFailsAHistoricalCycleBehindTheStart`: `run settled "succeeded" error ""`, want `failed/graph has a cycle`. Co-Authored-By: Claude Code <noreply@anthropic.com>
概要
接通 specs decisions/cloud/workflow/0-cloud-side-scheduler-owns-workflow-execution.md(specs#99 D1 条件②/④)落地顺序的第 3 步:Cloud 侧工作流调度器。对应 circus#70,运行 issue circus#141。
workflow_runs不再永远停在pending:新增后台推进循环(10s 一轮,批量上限 100,按created_at,id有序),认领pending运行、推进running运行,每个运行一个短事务。就绪集只从已提交状态重算——冻结快照的图 +node_states列,定点传播,与桌面端BranchProjection同语义:sourceHandle的旧图)selectedBranch匹配,未选中的分支下游记inactiveinactive(编排遗留节点不会劫持执行)节点结果在
versionCAS 之后提交(不变量③:晚到/重复的推进是 no-op;副本崩溃后从已提交状态精确恢复)。Response to review
1. "两条 CI 门禁挂在这份 head 上" — 已修。
checkformat与golangci-lint在本 head 上干净(gofumpt -l -extra无输出,lint 0 issues)。原先报出的errcheck两处、gofumpt一处、unconvert三处都已处理。2. "
readWorkflowGraph的裸类型断言会 panic" — 已修,且这是最严重的一处。 两处断言都改成 comma-ok(nodes, _ := graph["nodes"].([]any)),并补了注释说明为什么畸形文档要被丢弃而不是炸掉:能走到运行的图是发布门禁判过的,而早于该门禁的快照仍应得到一次诚实的运行。你指出的爆炸半径(transact的default: panic→ goroutine 未 recover → 整个 Cloud 进程退出 → 重启后每轮再 panic)完全成立,感谢定位。3. "记下来的运行失败原因不确定" — 已修。
workflowRunFailureReason原先遍历states(一个 map),Go 的 map 迭代顺序是随机的,所以多个节点失败时运行记录的原因会在每次重放之间变化——这让该字段失去作为证据的价值。现在按图的节点顺序遍历:每个已提交状态都属于某个图节点(调度器就是从图节点写的,且图在创建时冻结),所以按图遍历既能覆盖全部、又在重放间稳定。回归测试
TestWorkflowRunFailureReasonFollowsNodeOrder连续断言 200 次:在旧实现下它在前 15 次迭代内就失败,所以确定性是被钉住的,不是假定的。另有TestWorkflowRunFailureReasonFallbacks覆盖"失败但没带原因"与"没有任何失败"两种回退。4. "合并前先说一句" — 已按顺序 rebase。 本 head 现在基于当前 main(
7c9cd85,含 #65 与 #69)。你提示的三 PR 一起合才出现的重名(validateWorkflowSlots、rejectWith)在 #72 里已通过删除其自带副本解决——workflow_graph.go/workflow_slots.go现在只有 main 的一份实现。第一阶段边界(诚实终端态)
start/condition/output内联执行:start 报告运行输入;condition 按桌面端condition.rs全算子集求值(首个命中 case 胜出,全不命中落 else)并提交选中分支;output 是终结标记。agent 节点是阶段边界:在执行接线落地之前,就绪的 agent 节点会让运行以agent_execution_not_wired失败——诚实的终端态,绝不挂在running,绝不为没发生过的工作报成功。(#72 落地后该原因被具名永久原因取代:
workflow_run_project_required/agent_plugin_version_unfrozen/session_start_deadline_exceeded/agent_failed/interrupted/cancelled。)这样生产环境的 start→condition→output 图能真实跑通并正确着色(含
inactive分支渲染,前端已支持),含 agent 的图快速失败并指名缺口;开发夹具的模拟器行为不变。已知修复
walkWorkflowOutputPath等文档级断言原先直接对map[string]any断言——Object是命名类型,断言会静默失败,生产环境(workflowStatesFromJSON产出 Object 包装的 output)所有条件求值都会落到 absent→else。测试用手写Object构图暴露了这一点;已统一走asWorkflowObject(同时接受两种动态类型)。依赖说明
已 rebase 到当前上游 main(
7c9cd85,含 #65 的 admit/publish 门与 #69 的会话看板)。#65 带来的workflowSnapshotId冻结与 slot 校验与本 PR 不冲突:本 PR 只消费已冻结的快照,不重新解析。测试
go build ./...、go vet ./internal/... ./cmd/...、go run ./cmd/checkformat、go tool golangci-lint run(0 issues)、go run ./cmd/openapi(零漂移)全部通过TestWorkflowRunFailureReasonFollowsNodeOrder/TestWorkflowRunFailureReasonFallbacks这不是"全部测试通过"。 有 4 个 Windows 环境性包失败,且每一个都在未改动的基线
7c9cd85上逐条复现一致:internal/gatewayTestWebServesFilesShellAndLeavesReservedPathssymlink ...: A required privilege is not held by the client.(Windows 符号链接权限)internal/gitserver(8 例)New with a git lacking http-backend(本机 git 无git http-backend)cmd/devsetupTestMaterializeKeepsKeysAndRewritesDerivedFilesmode = 666, want 600(Windows 无 POSIX mode 位)integrationTestGitServerAuthorizesDeliveryPushFromCloudStategit http-backend其余
./internal/...、./cmd/...、./integration/...全部通过。后续(不在本 PR)
执行接线(PR B,即 #68):migration 0035(
execution_work/node_executions/workspaces的 WorkflowRun 归属,D8 判别列 + exactly-one 约束)、workflow_runs.created_by/project_id、session start/settle/release 的 workflow 分支、runtime_control分支——已记录在 circus#141。会话执行与槽位绑定在 PR C(#72)。🤖 Generated with Claude Code
本地复跑证据(2026-10-17)
在堆叠栈 tip 上复跑(
fc944ca= #72 head,含 #67 与 #68 的全部内容;其上另有 2 个尚未推送的本地提交,其中一个改internal/core/workflow_run.go的可空时间戳投影)。go build ./...go vet ./internal/... ./cmd/...go run ./cmd/checkformatgo tool golangci-lint config verify+rungo test ./internal/core/... -count=1go test ./integration/... -count=1typecheck/lint/format:check/check:modules/check:docs/check:dead/check:dup/buildvitest run src/features/workflowsnpm run api:generate漂移diff --strip-trailing-cr逐一核实后还原)这不是「全部通过」。
integration的TestGitServerAuthorizesDeliveryPushFromCloudState在本机失败:已与未改动的基线
7c9cd85(upstream/main,本栈 rebase 的起点)逐条对照:在该基线的独立 worktree 上跑同一条用例,得到完全相同的失败信息,所以这是 Windows 环境性失败,不是本栈引入的。本栈也没有改动integration/git_server_test.go或internal/gitserver/(git diff --stat 7c9cd85..HEAD -- <这两个路径>为空)。日志保留在本地.local/test-run-2026-10-17-tip3620455.log。按 owner 结论补上发布门禁(circus#202 / #141,2026-10-17)
ADR 状态:
decisions/cloud/workflow/0-cloud-side-scheduler-owns-workflow-execution.md、20261015-executor-slots-replace-agent-model-ids.md、20261015-executor-slot-mappings-and-per-slot-run-bindings.md均保持
approved,本 PR 不改其状态。本 PR 实现 D1 条件②(无状态调度、单 run 串行、从已提交状态重算)与D2 的一部分(规范图 schema 的节点类型与节点配置校验),不是 D1–D8 的全部落地:真实执行侧(D1 落地顺序
第 3 步)、取消写入者(D6)、IssueRun ↔ WorkflowRun 引用(D3 的
workflow_invocation_ref)都不在本 PR 内,因此 ADR 不标
implemented。当前不支持的 condition 配置(清单)
owner 在 circus#202 选 A,条件 2 要求把"当前不支持的 condition
配置"清单写进本描述和 ADR,门禁对这些配置给出可定位的拒绝原因(节点、字段、原因),不能发布后在运行时判错。
ADR 侧同步在 specs PR(见下)。清单即
internal/core/workflow_graph.go的workflowUnsupportedConditionConfigs,渲染成
400 workflow_graph_invalid的problems[],每项带code与nodeId,并按需带field:code)condition_cases_not_a_listdata.casescondition_case_not_an_objectdata.cases[]condition_case_id_missingdata.cases[].idcondition_logic_unsupporteddata.cases[].logiclogic不是and/or(缺省即and):Cloud 会静默按and求值condition_rules_not_a_listdata.cases[].conditionscondition_rule_not_an_objectdata.cases[].conditions[]condition_selector_unsupporteddata.cases[].conditions[].variableSelectorcondition_operator_unsupporteddata.cases[].conditions[].operatorComparisonOperator::parse的全集):Cloud 会静默判为不匹配condition_value_template_unsupporteddata.cases[].conditions[].value{{#…#}}:desktop 解析它并保留绑定类型,Cloud 按字面文本比较未完成的分支不拒绝:没有
cases、或某个分支还没有比较项,都按"落到 else"处理,两侧语义一致,属于草稿的正常中间状态。
不阻塞本 PR、已拆到 circus#210 的差异:数组/对象上的
contains、非字符串上的
starts_with/ends_with、期望值模板的解析语义、选择器的取值遍历。这些只在值被解析出来之后才成立,文档本身看不出来,所以无法在发布时判定。#210 完成后逐项删掉上表里相应的拒绝项——上表是会缩短的清单,不是
condition 的最终语义。
工作流资产导入走同一道门禁:
validateWorkflowGraph是发布校验的唯一入口(requireValidWorkflowGraph的唯一调用点是
publishWorkflow),导入路径落地时(circus#73)逐份文档调用它,因此插件包里带的不支持写法按单份文档失败,不影响同包其他文档。导入本身不在本 PR 内。
实现与测试
internal/core/workflow_graph.go:validateConditionConfig/validateConditionRule走data.cases,算子表直接读求值器自己的
workflowConditionOperators,所以门禁与求值器不可能各持一份算子清单。internal/core/workflow_graph_test.go:TestWorkflowPublishRefusesUnsupportedConditionConfigs13 个用例,并把用例与
workflowUnsupportedConditionConfigs双向核对(声明的码没有用例 → 失败;用例产出的码没被声明 → 失败),所以 ADR 的清单、本描述的清单与门禁的行为不可能各说各话;另有三份必须不被拒绝的文档(已填好的节点、还没有
case 的节点、未完成的 case)。
case kind == "condition"的挂载点后 13 个用例全部失败、集合核对报map[],证明用例咬得住这个门禁。go test ./internal/... ./cmd/...在本 head 上通过(4 个 Windows 环境性包失败与基线一致,见上)。合并前仍缺(阻塞合并,不阻塞本 PR 的代码)
core-test 证据的 specs PR:circus#141 的证据盘点
把"提交 specs PR 同步 core-test 证据"列为 owner 的合并前检查项,本 PR 的 condition 行与
scheduler-serialism-and-recovery.md在其中。证据 PR 与产品 PR 分开提交。
🤖 Generated with Claude Code