Skip to content

feat: Cloud 侧工作流调度推进器(specs#99 落地第 3 步) - #67

Merged
EricWvi merged 6 commits into
ora-space:mainfrom
Kyber5323:feat/141-workflow-run-scheduler
Oct 17, 2026
Merged

EricWvi merged 6 commits into
ora-space:mainfrom
Kyber5323:feat/141-workflow-run-scheduler

Conversation

@Kyber5323

@Kyber5323 Kyber5323 commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

概要

接通 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 同语义:

  • 无标签边 = else 边(兼容没有 sourceHandle 的旧图)
  • condition 边按已提交的 selectedBranch 匹配,未选中的分支下游记 inactive
  • 无入边的非 start 节点 = inactive(编排遗留节点不会劫持执行)
  • 上游未决时下游不解析(不变量⑤:没有已提交结果的节点绝不记为成功)

节点结果在 version CAS 之后提交(不变量③:晚到/重复的推进是 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(零漂移)全部通过
  • 纯逻辑测试:投影(线性/分支选中与未选中/无标签边/无根节点/上游等待/失败传播)、16 个条件算子(含 absent 语义)、and/or 与空 case 逻辑
  • DB 测试(隔离 schema、真 PostgreSQL):claim+settle 全链路(version=1→3)、agent 节点诚实失败(下游无状态提交)、三轮推进幂等
  • 新增:TestWorkflowRunFailureReasonFollowsNodeOrder / TestWorkflowRunFailureReasonFallbacks

这不是"全部测试通过"。 有 4 个 Windows 环境性包失败,且每一个都在未改动的基线 7c9cd85 上逐条复现一致:

包 原因(实测)
internal/gateway TestWebServesFilesShellAndLeavesReservedPaths symlink ...: 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/devsetup TestMaterializeKeepsKeysAndRewritesDerivedFiles mode = 666, want 600(Windows 无 POSIX mode 位)
integration TestGitServerAuthorizesDeliveryPushFromCloudState 同 git 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/checkformat ✅
go tool golangci-lint config verify + run ✅ 0 issues
go test ./internal/core/... -count=1 ✅ 全部通过
go test ./integration/... -count=1 ⚠️ 1 个环境性失败,见下
前端 typecheck / lint / format:check / check:modules / check:docs / check:dead / check:dup / build ✅ 全部通过
vitest run src/features/workflows ✅ 54 文件 / 577 用例通过
npm run api:generate 漂移 ✅ 零真实漂移(3 个文件仅 CRLF 差异,已用 diff --strip-trailing-cr 逐一核实后还原)

这不是「全部通过」。 integration 的 TestGitServerAuthorizesDeliveryPushFromCloudState 在本机失败:

--- FAIL: TestGitServerAuthorizesDeliveryPushFromCloudState (2.77s)
    git_server_test.go:109: git server requires git http-backend

已与未改动的基线 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_list data.cases 分支列表不是列表
condition_case_not_an_object data.cases[] 分支不是对象
condition_case_id_missing data.cases[].id 分支没有 id,命中后返回空串,边永远引用不到它
condition_logic_unsupported data.cases[].logic logic 不是 and / or(缺省即 and):Cloud 会静默按 and 求值
condition_rules_not_a_list data.cases[].conditions 比较项列表不是列表
condition_rule_not_an_object data.cases[].conditions[] 比较项不是对象:Cloud 会静默判为不匹配
condition_selector_unsupported data.cases[].conditions[].variableSelector 选择器不是「至少两个非空字符串」的列表;一段选择器在 Cloud 读作整个节点输出
condition_operator_unsupported data.cases[].conditions[].operator 算子不在求值器算子表里(即 desktop ComparisonOperator::parse 的全集):Cloud 会静默判为不匹配
condition_value_template_unsupported data.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:TestWorkflowPublishRefusesUnsupportedConditionConfigs 13 个用例,并把用例
    与 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

zhangshilong123 added a commit to zhangshilong123/cloud that referenced this pull request Oct 16, 2026
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 Zpc001 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:113 nodes, _ := graph["nodes"].([]any)
  • internal/core/workflow_graph.go:124 nodes, ok := graph["nodes"].([]any)
  • internal/core/workflow_graph.go:211 edges, _ := 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

@Kyber5323
Kyber5323 force-pushed the feat/141-workflow-run-scheduler branch 3 times, most recently from fd285a1 to efb7066 Compare October 16, 2026 10:02
Kyber5323 pushed a commit to Kyber5323/cloud that referenced this pull request Oct 17, 2026
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>
@Kyber5323
Kyber5323 force-pushed the feat/141-workflow-run-scheduler branch from efb7066 to 12a3fe4 Compare October 17, 2026 06:19
Kyber5323 pushed a commit to Kyber5323/cloud that referenced this pull request Oct 17, 2026
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>
Kyber5323 pushed a commit to Kyber5323/cloud that referenced this pull request Oct 17, 2026
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 Zpc001 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

复审结论: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 条都已确认修复:

  1. 格式与 lint 干净。
  2. readWorkflowGraph 已改为 comma-ok(workflow_scheduler.go:125,145)。
  3. 失败原因按 graph.Nodes 顺序遍历,200 次循环用例能稳定拦住旧实现。
  4. 与 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 把非字符串序列化后比较(number 12 starts_with 1 为 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”。

t and others added 3 commits October 17, 2026 16:49
…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>
Kyber5323 pushed a commit to Kyber5323/cloud that referenced this pull request Oct 17, 2026
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 Zpc001 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

复审 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 再复审。

Kyber5323 pushed a commit to Kyber5323/cloud that referenced this pull request Oct 17, 2026
…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>
Kyber5323 pushed a commit to Kyber5323/cloud that referenced this pull request Oct 17, 2026
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>
@EricWvi
EricWvi merged commit d619d12 into ora-space:main Oct 17, 2026
1 check passed
Kyber5323 pushed a commit to Kyber5323/cloud that referenced this pull request Oct 17, 2026
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>
Kyber5323 pushed a commit to Kyber5323/cloud that referenced this pull request Oct 19, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants