Skip to content

feat(core): evaluate conditions with desktop's semantics - #92

Merged
EricWvi merged 11 commits into
ora-space:mainfrom
Zpc001:feat/215-condition-parity-rebased
Oct 19, 2026
Merged

EricWvi merged 11 commits into
ora-space:mainfrom
Zpc001:feat/215-condition-parity-rebased

Conversation

@Zpc001

@Zpc001 Zpc001 commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

本 PR 做什么

按 desktop engine/condition.rs(参考实现)补齐 Cloud 的 condition 求值语义,关掉 ora-space/circus#210 的四项差异。此前发布门禁对这些写法的拒绝是「天花板」而非「已对齐」——数组/对象 contains、非字符串 starts_with/ends_with、期望值模板、选择器取值这四项直接不可用。现在:

  1. contains:字符串做子串匹配;数组做元素相等;其余类型算类型不匹配。
  2. starts_with / ends_with:两侧都要求字符串。数值比较要求两边都是数。
  3. 期望值模板 {{#node.var#}}:按 Dify 风格解析。独占占位符保留绑定 JSON 类型,混合模板渲染成字符串,未设置或畸形的选择器让节点失败,而非拿占位符文本去比。
    字面 "value": null 按 desktop 的 Option<Value> 读作缺失操作数(比较算子报类型错误);独占模板解析出 null 仍是可比较的现值。
  4. Start 节点的变量根:input 是 kickoff 提示词(保留作者写入的空白,不 trim),每个 run-form 变量各自成根;Cloud 之前把整个 run input 嵌套在 input 下,同一选择器两侧读出不同值。

另有两处语义收紧:缺失变量只由 exists/not_exists/empty/not_empty 回答,其余算子(含 not_contains)遇到未设置变量一律让节点失败,而不是静默落到 else;畸形的 case/rule/operator 让节点(进而整个运行)失败。

发布门禁同步调整:去掉 condition_value_template_unsupported;新增 condition_selector_undeclared——desktop 从图里预先声明每个节点的变量根,选择器指向图中没有的节点或该 kind 不暴露的根时是 Undeclared 并让节点失败,而 Cloud 运行时没有声明集、只会回答「缺失」,于是允许 exists 走出一条作者写不出的分支。节点和 kind 都在图里,发布时即可判定,因此由门禁拒绝。期望值模板里的 {{#node.root#}} 占位符引用同一份声明集,同样在发布期校验(field 记 cases.conditions.value);sys.* 与用户自定义的 globalVariables 在 Cloud 既没有值也没有声明,一并拒绝。历史快照仍按「缺失」求值。

调度器另修一处:同一波里已有节点失败时立即停止派发,不再为已经注定失败的运行去启动 agent 会话(会真的创建 Workspace 并等待 session),未运行的兄弟节点也不留状态。开发用模拟器的 Start 输出同步为真实形状。求值器重写在 internal/core/workflow_condition.go,模板渲染拆到 internal/core/workflow_condition_template.go(控制文件体积)。desktop 对拍用例落在 internal/core/workflow_condition_parity_test.go,门禁的正反用例、失败不派发兄弟节点、Start 提示词保留空白都有覆盖。

ADR 状态

依据 0-cloud-side-scheduler-owns-workflow-execution.md,维持 approved:本 PR 只补齐 D1 的 condition 语义(#210 四项差异),不涉及调度器整体落地。拒绝表因此回到 9 项(去一项、增一项),「另一类差异」段落里指向 #210 的四项做完——specs 同步由 ora-space/specs#133 提交。

合并顺序:先合本 PR,再合 specs#133,使 specs 引用的对拍测试先落到 main。

验证

  • gofmt -l、go vet ./...、go tool golangci-lint run -c .golangci.yml ./internal/core/... ./internal/collab/... 干净。
  • REQUIRE_POSTGRES=1 TEST_DATABASE_URL=… go test ./... 全过(含 integration;环境限制下的 S3/DNS SKIP 不是失败)。

Refs ora-space/circus#215

🤖 Generated with Claude Code

The publish gate could refuse condition documents Cloud evaluated differently
from desktop, but refusing was the ceiling: array/object `contains`, non-string
`starts_with`/`ends_with`, expected-value templates and the selector shape were
unavailable rather than aligned. This closes circus#210's four differences
against desktop's engine/condition.rs, which is the reference implementation.

- contains is substring for strings and element equality for arrays; other
  types are a type mismatch. starts_with/ends_with require strings on both
  sides. Numeric ordering requires two numbers. Absence is answered only by
  exists/not_exists/empty/not_empty; every comparator, not_contains included,
  fails on an unset variable instead of silently taking else, and a malformed
  case/rule/operator fails the node and the run.
- Expected values resolve Dify-style `{{#node.var#}}` templates: an exclusive
  placeholder keeps the bound JSON type, a mixed template renders a string, and
  an unset or malformed selector fails rather than comparing placeholder text.
- The Start node exposes desktop's variable roots: `input` is the kickoff
  prompt and every run-form variable is its own root. Cloud previously nested
  the whole run input under `input`, so the same selector read different values
  on the two sides.
- The publish gate drops condition_value_template_unsupported; the other
  refusals remain.

Tests mirror the named desktop cases for each difference and cover the start
node's roots and the failed-node path. The condition template renderer moved
to a sibling file to keep both files under the repository's size guidance.

Refs ora-space/circus#215

Co-Authored-By: Claude Code <noreply@anthropic.com>
@Zpc001
Zpc001 force-pushed the feat/215-condition-parity-rebased branch from 162187e to 606949e Compare October 19, 2026 08:35

@wanglongan587 wanglongan587 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

结论

请求修改。四类求值语义我逐项对过 desktop condition.rs(ed91d18)与 variable_pool.rs,实现是对的;失败传播和门禁也对。要求修改的两点不在算子里,而在变量池的边界和注释:exists/not_exists 遇到「描述里根本没有的变量」时 Cloud 静默回答 absent,desktop 会让节点失败;新文件头注释列的四个节点的根,有三个 Cloud 今天并不产出。

核对方式:以 606949e 检出,读 diff 与新旧全文,对拍 desktop upstream/main(ed91d18);本地跑通 go test ./internal/core/(无 TEST_DATABASE_URL,DB 用例跳过,所以没有复现作者的 PostgreSQL 全套,TestWorkflowAdvanceConditionNotContainsOnAbsentVariable 是读代码确认的)。

我核对过、与 desktop 一致的部分

  1. contains:字符串按子串,数组按元素相等,object/标量走类型错误 —— 分别对应 desktop contains() 返回 Some/None 的两条分支(condition.rs string_operator_on_object_fails_as_type_mismatch、string_operators_match_on_text)。
  2. starts_with/ends_with:两侧都必须是字符串,数值不再是文本比较;desktop starts_with/ends_with 的 as_str()? 行为一致。表里 {"starts_with non-string actual/expected is a type error"} 覆盖到位(不在三个 parity 测试里,见 specs 侧意见)。
  3. 期望值模板:独占 {{#…#}} 保留绑定 JSON 类型、混合渲染成字符串、字符串原样/其余按 JSON 渲染(键序一致,因为 encoding/json 与 serde_json 都排序)、未赋值/未闭合/畸形选择器报错,和 resolve_comparison_value + render_variable_template 逐条对上。exists/empty 也照样先解析期望值,与 desktop 在 evaluate_rule 里无条件解析一致。
  4. 失败传播:condition 无法求值时提交 failed + reason,workflowRunFailureReason 按图顺序取第一个失败节点的 reason 作为运行 error;TestWorkflowAdvanceConditionNotContainsOnAbsentVariable 断言运行 failed、节点 failed、error 里点名 s.never_written。or 用例先求全部 rule 再合并,不会把后面未赋值的 rule 短路掉 —— 与 desktop「先 collect 全部 Result 再 all/any」一致。
  5. 错误顺序:先解析实际值再解析期望值,与 evaluate_rule 同序;根存在但嵌套路径缺失/非对象报错,对应 desktop PathMissing/PathNotObject 也报错。
  6. 门禁只删一项:workflowUnsupportedConditionConfigs 9 → 8,删掉的正是 condition_value_template_unsupported,validateConditionRule 里对应那段也一起删了;TestWorkflowPublishRefusesUnsupportedConditionConfigs 的双向核对仍然保证「清单里的每个码有用例、用例产出的每个码在清单里」,并新增独占/混合两种期望值模板都能发布。全仓没有残留引用(grep condition_value_template_unsupported 为空)。

需要处置

1. 未声明 ≠ 未赋值:exists/not_exists/empty/not_empty 在「描述里没有的选择器」上静默取 absent

resolveWorkflowSelector(internal/core/workflow_condition.go:101)只从 states 里找:节点不存在、根不存在、节点不是 succeeded,一律 present=false。desktop 不是这样:WorkflowVariablePool::from_graph 先按图声明变量,resolve 对「不在 catalog 里」返回 Undeclared,对「声明了但还没赋值」才返回 Ok(None);Undeclared 经 map_pool_resolve_error 变成 ConditionError::Undeclared,节点和运行失败。

后果:一个选择器指向图里没有的节点、节点种类永远不会产出的根、或运行输入里多出来的键时,Cloud 的 exists/not_exists 会给出答案(进而选分支或落 else),desktop 直接失败。穷举这类:[start, 已删除的变量名]、[agnet-a, output](拼错节点)、[agent-a, structured_output](agent 没配 structured 契约,而 outputContract 本身又被门禁拒绝,所以这个根永不存在)、[cyc, ...]。这些正好是 #210 验收里那句「desktop 报类型错误或未赋值错误的配置,Cloud 必须有明确结论(同样失败,或发布期拒绝),不能静默落到 else」想拦的第三类错误。

而且这类差异能从文档本身判定(节点 id 在不在图里、该节点种类暴露哪些根),按 D2 的判据完全可以进发布门禁,不需要运行时才知道。

请二选一:

  • (a) 进发布门禁:condition_selector_unsupported 之外补一条「选择器的节点必须存在,且根必须是该节点种类暴露的根」(start:input + inputVariables;agent:output(+ 有 structured 契约才有的 structured_output);output:output;condition:不暴露任何根),并给负向用例;或
  • (b) 明确记为残留差异:由 owner 认可后写进 specs 的那段「已对齐」里,同时保留一条测试把这个行为钉住(注释要写清 desktop 是 Undeclared 失败,不是 absent),并开后续 issue。

现在 TestWorkflowConditionCaseLogic 的注释「an exists rule on a node that never ran matches nothing」把两件事混成了一件事;TestWorkflowConditionDesktopParity 的「existence tolerates an unassigned variable」用的是 states 里根本没有 agent-a,而 desktop 的 existence_and_empty_handle_unset_variables 是声明了 agent-a.text 但没赋值。要让这条对拍真的对上 reference,Cloud 侧应写成 states["agent-a"] = {succeeded, Output: {}}(根不在 → 未赋值),并另加一条「节点/根不存在 → desktop 失败」的区分用例。

2. workflow_condition.go 头注释的变量根清单与实现不符(condition: selected_branch_id 等)

新头注释(internal/core/workflow_condition.go:12-25)写明选择器是 [nodeId, root, ...nested],并把四个节点的根写成 desktop 的形状:

  • condition: selected_branch_id —— Cloud 实际写的是 selectedBranch(workflow_scheduler.go:646),而且 desktop 的池根本不声明 condition 的变量(declare_base_node_variables 的 _ => {}),编辑器目录也刻意不暴露(packages/workflow-mock/src/variable-catalog.ts「Conditions only route control flow…」)。所以这个根在 desktop 上是 Undeclared、在 Cloud 上是 selectedBranch,两边都不该被当成可用根。
  • agent: output/structured_output —— Cloud 的 agent 节点终态只有 Object{"reason": …}(workflow_run_session.go:112),这两个根一个都不产出;编辑器目录却正是给作者提供 [agent, output] 和 [agent, structured_output, …]。
  • output: output —— Cloud 的 output 节点写的是 Object{}(workflow_scheduler.go:648)。

也就是说,今天 Cloud 的池只有 start 的根 是对的。文件头是后来人(和 agent)理解这个池的第一处依据,写成 desktop 的清单会让人直接按它写选择器。请把注释改成「Cloud 今天实际产出什么」,并点名谁补其余的根(我理解是 #70 的变量传递 / #74),或者在本 PR 里补上(那会扩范围,可能需要 owner 先同意)。

建议与小问题

  1. 失败后同一 pass 还会派发 sibling agent。 AdvanceWorkflowRunsOnce 的循环(workflow_scheduler.go:525-540)在 condition 失败后不检查失败就继续取下一个 ready 节点;若同一个 wave 里还有 agent 节点(例如 start 同时连 condition 和 agent,document order 里 condition 在前),会给一个已经注定失败的运行起 Workspace 和 agent session,等 session 结束才结算失败。建议在派发前加 if projection.hasFailure() { break },让失败当轮就结算。

  2. workflowTypeLabel 的注释与 desktop 不符。 注释说「Integer-valued floats are called integers, matching desktop's type_label」,但 desktop 只把 is_i64()/is_u64() 叫 integer,92.0 是 number。Cloud 把 92.0 标成 integer。只影响报错文案,改注释或改实现都行,但目前注释是错的。

  3. JSON 相等与 desktop equal() 的两处边缘差异(低优先级,可只在注释里记一句):容器内部的数字,desktop 走 serde_json::Value 相等,{a:2} 与 {a:2.0} 不相等(N::PosInt ≠ N::Float),Cloud 规范化 JSON 后相等;-0.0 与 0,desktop 的 as_f64 相等,Cloud 的规范形 "-0" ≠ "0"。顶层数字比较两边一致。

  4. workflowStartOutput 的三处小差异:strings.TrimSpace 修剪了提示词(desktop 用原样 kickoff 文本);运行输入里名为 input 的表单变量被静默丢弃(注释写了理由,但作者不会知道它没进池);run.input 里未在 Start 表单声明的键会成为根(requireWorkflowRunInput 只校验已声明字段),与第 1 点同源。另外 dev simulator 仍写 {"input": <整个 run input>}(internal/collab/workflow-run-simulator.go:50),与真实执行的 start 输出形状已经不一致,UI 负例看到的和真跑出来的不同。start.input 的 kickoff 覆盖仍待 owner 拍板((a)/(b)),本 PR 的写法把「用编辑器里的默认提示词」写清楚了,这点没有问题。

Zpc001 and others added 7 commits October 19, 2026 18:58
Desktop builds its variable pool from the graph, so a selector naming a
node or root the graph never declares is Undeclared there and fails the
node. Cloud's evaluator has no declaration set and would answer absent
instead, letting exists/not_exists pick a branch the author could not
have written. The graph decides both facts, so the publish gate now
refuses such selectors as condition_selector_undeclared.

Co-Authored-By: Claude Code <noreply@anthropic.com>
A node that fails decides the run, but the advance pass kept asking for
the next ready node in the same wave. A ready agent sibling would then be
dispatched for a run already terminal: it provisioned a Workspace and
waited on a session only to settle failed afterwards, and it committed a
state claiming work that never had a chance. Stop the wave as soon as the
projection reports a failure, so the pass settles the run and the unrun
siblings keep no state.

Co-Authored-By: Claude Code <noreply@anthropic.com>
The Start node's `input` root is desktop's kickoff prompt as the author
wrote it. Cloud trimmed it, so a condition comparing that selector could
match differently on the two sides. Store it verbatim, and make the dev
simulator report the same shape: run-form variables as their own roots
and `input` as the authored prompt.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Correct three claims the review found inaccurate: the variable pool now
names the roots each node kind declares and the publish gate that stands
in for undeclared selectors at run time; the type-label note says Cloud
labels any integral float "integer" while desktop distinguishes it; and
the equality note records the container int/float and -0.0 edge
differences. The parity fixture now models desktop's declared-but-unset
variable with an agent node present, since an undeclared selector is a
different case.

Co-Authored-By: Claude Code <noreply@anthropic.com>
The operator table covered starts_with's two non-string operands but not
ends_with's. Both share workflowCompareTextOrder, and the specs evidence now
names this table as the direct evidence for that type error.

Co-Authored-By: Claude Code <noreply@anthropic.com>
The gate's comment and the specs decision name two cases: a selector on a
node the graph does not contain, and a root the node's kind does not declare.
Only the second had a case. Add the first with `sys.workflow_id`: desktop
declares the system globals, Cloud neither provides nor declares them.

Co-Authored-By: Claude Code <noreply@anthropic.com>
The case-logic test called the undeclared-node path "a node that never ran",
which under option (a) means a graph node that has not produced output yet.
Say that the states have no such node, that desktop fails it as Undeclared,
and that only pre-gate snapshots reach this runtime answer.

Co-Authored-By: Claude Code <noreply@anthropic.com>
@Zpc001

Zpc001 commented Oct 19, 2026

Copy link
Copy Markdown
Contributor Author

已按检视意见修改,逐条回复。

处置 1:未声明选择器选 (a),进发布门禁

新增 condition_selector_undeclared(internal/core/workflow_graph.go),按图声明每个节点种类的根:Start 是 input + 每个 inputVariables;Agent 是 output,带 agentConfig.outputContract 时再加 structured_output;Output 是 output;Condition 不声明。选择器指向图中没有的节点,或节点存在但该 kind 不声明这个根,发布即拒绝。

反例覆盖两半:a selector on an undeclared root([s, missing])与 a selector on a node the graph does not contain(sys.workflow_id,desktop 的池声明系统全局,Cloud 既不提供也不声明)。正例补了 Start 的 input / inputVariables 根、Agent / Output 的 output 根,workflowUnsupportedConditionConfigs 的双向核对仍然成立。

TestWorkflowConditionDesktopParity 的池改成 agent-a 为 succeeded 且输出为空,对应 desktop 手工声明但未赋值的 agent-a.text;TestWorkflowConditionCaseLogic 的 ghost 用例注释改成「states 里没有这个节点 → 运行时按缺失回答;desktop 会 Undeclared 失败;仅门禁上线前的已发布快照会走到这里」,不再和「节点没跑」混为一谈。

门禁只作用于新的发布;历史快照维持运行时「缺失」语义,已写进 specs 的 ADR(specs#133)。

处置 2:头注释改为 Cloud 实际产出的根

workflow_condition.go 文件头现在写明:今天只有 Start 真正写入根;Agent / Output 的根由契约声明、值随 #70/#74 到位;Condition 不声明任何根。删掉了 selected_branch_id,也不再暗示另外三类根今天可用。

建议 3:失败后不再派发 sibling

advanceWorkflowRun 的循环在取 ready 节点前检查 projection.hasFailure(),失败当轮直接结束;新增 TestWorkflowAdvanceDoesNotDispatchSiblingAfterFailure(真实 PostgreSQL):condition 失败时同 wave 的 agent 兄弟节点不落任何状态,运行当轮 failed。

建议 4 / 5:注释

  • workflowTypeLabel:改成说明 Cloud 全部按 float64 解码,整数值标 integer;desktop 只在 is_i64 / is_u64 时叫 integer,92.0 是 number。差异只在报错文案。
  • workflowJSONEqual:写明容器内整数/浮点在 desktop 不相等而 canonical form 统一,以及 -0.0 与 0 的两侧差异;顶层数字比较一致。

建议 6:Start 输出

  • 去掉 TrimSpace,提示词按作者原文(含空白)进入池。
  • 运行输入里名为 input 的键被丢弃:desktop 的 declare 先声明保留根、表单变量同名时不会覆盖(declare 保留首次声明),两侧一致,注释保留了这个理由。
  • dev simulator 的 Start 输出改成真实形状:表单变量各自成根,input 是 kickoff 提示词。
  • start.input 的 kickoff 覆盖 (a)/(b) 仍等 owner 在 circus#215 拍板,本 PR 不预设。

验证

  • go test ./...(REQUIRE_POSTGRES=1,真实 PostgreSQL)全过,含 integration。
  • go tool golangci-lint run -c .golangci.yml ./internal/core/... ./internal/collab/...:0 issues。
  • gofmt -l、go vet ./... 干净。

请再看一遍。

@Zpc001

Zpc001 commented Oct 19, 2026

Copy link
Copy Markdown
Contributor Author

@wanglongan587 已按检视意见推送修复(head 7d29453),方便时请复查。当前账号对上游仓库没有 triage 权限,GitHub 的 re-request review 接口返回 404,只能在这里知会。

@wanglongan587 wanglongan587 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

复审结论:Request changes(审到 7d29453)

上次审的是 606949e;这次逐条看了新增的 7 个 commit,并按 desktop upstream/main(3fa5fc0)的 condition.rs、variable_pool.rs 和 wire 反序列化核对。

上次检视意见已处理

  • 未声明选择器按 (a):condition_selector_undeclared + workflowDeclaredRoots,负向用例覆盖「节点不在图中」与「kind 不声明该根」;TestWorkflowConditionDesktopParity 的池改成声明但未赋值的 agent-a。
  • workflow_condition.go 头注释改为 Cloud 实际产出的根,并点名 #70/#74。
  • condition 失败后不再派发同 wave 的 sibling(带 DB 用例);workflowTypeLabel / workflowJSONEqual 注释、Start 输出的 TrimSpace、dev simulator 的 Start 形状都已处理。

本地验证:go test ./internal/core/ ./internal/collab/ 通过(没有 TEST_DATABASE_URL,DB 用例跳过);go vet ./internal/core/ ./internal/collab/ 与 gofmt -l 干净。

需要修:显式 "value": null 被当成期望值

resolveWorkflowExpected 在 value 键存在且为 null 时返回 (nil, true),注释也写「explicit null is a value desktop compares against」。但 desktop 的 WireConditionRule.value 是 Option<Value>(crates/application/src/workflow_run/engine/condition.rs),serde 把 JSON null 读成 None;ComparisonOperator::evaluate 在 expected: None 时对比较算子返回 TypeMismatch { expected: "a comparison value" }(我用 1.95 工具链跑了一个最小 serde 程序确认 Option<Value> 对 null 的结果是 None)。

所以同一份文档在两端会分叉:

  • actual 是 null、"value": null、equals:Cloud 命中,desktop 报 TypeMismatch;
  • not_equals / contains(数组里有 null)等同理。

这违反 #210 验收里「desktop 报类型错误的配置,Cloud 不能静默走分支」。TestWorkflowConditionOperators 的 {"equals null", ...} 目前把 Cloud 的错误行为钉住了,parity 测试也没有这个 case。

建议按 desktop 处理:value 存在但为 null 时返回 hasExpected=false(等价于反序列化成 None),把 equals null 用例改成期望错误,并补一条 wire 形状的对拍用例。若确实要保留「显式 null 可比较」,就需要在 specs 里写成残留差异并由发布门禁拒绝这种配置;但那样和现在「已对齐」的结论冲突。

非阻塞

  • workflowDeclaredRoots 对任意非 null 的 outputContract 都声明 structured_output;desktop 只对 OutputContract::Structured 声明。今天 outputContract 在 workflowUnsupportedAgentConfigFields 里,带它的图会先被 node_config_unsupported 拒绝,所以这段不可达;但 ADR 和 PR 描述的「带 outputContract 时」最好与 desktop 的 Structured 条件对齐,等 #70/#74 支持契约时再补这个分支。
  • 混合模板的渲染用 canonicalJSON,整数值的 float 会渲染成 2;desktop 对 2.0 渲染 2.0(serde_json 保留 JSON 数字拼写)。Cloud 解码后分不出 2 和 2.0,建议像 workflowJSONEqual 的注释那样把这条残留差异写清楚,避免读者把「混合模板」理解成逐字节一致。

合并仍由 owner 决定。

@wanglongan587 wanglongan587 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

复审补充:Request changes(审到 7d29453)

承接 19:29 对同一 head 的复审。上一轮的两条仍待处理,本轮又发现一处新的门禁缺口。

核对基准:cloud 7d29453、desktop upstream/main 3fa5fc0(crates/application/src/workflow_run/engine/condition.rs、variable_pool.rs)。本地 go test ./internal/core/ -run 'TestWorkflowCondition|TestWorkflowPublishRefusesUnsupportedConditionConfigs' 通过。

仍待修 1:显式 "value": null 的语义

resolveWorkflowExpected(internal/core/workflow_condition_template.go:14-22)在 value 键存在且为 null 时返回 (nil, true),注释也写「explicit null is a value desktop compares against」。但 desktop 的 WireConditionRule.value 是 Option<Value>,serde 把 JSON null 读成 None;ComparisonOperator::evaluate 在 expected: None 时对比较算子报 TypeMismatch。

所以 actual=null + "value": null + equals 在 Cloud 命中、在 desktop 失败;not_equals / contains(数组里有 null)同理。TestWorkflowConditionOperators 的 {"equals null", ..., true, false} 把 Cloud 的错误行为钉住了。

请按 desktop 处理:仅对字面 value: null 返回 hasExpected=false;独占模板解析出 null 时仍应保留 hasExpected=true(desktop 那里是 Some(Value::Null),可以真的比较)。exists / empty 不受影响。

新发现 2:condition_selector_undeclared 只检查 variableSelector,没检查期望值模板里的选择器

validateConditionRule(internal/core/workflow_graph.go)只对 r["variableSelector"] 查 declaredRoots;value 里的 {{#node.root#}} 原样放行。于是下面这份文档能发布,且 Cloud 会走出一条 desktop 不可能走的分支:

start s → condition c0(rule: [s,input] equals "kickoff")
        → condition c1(rule: [s,input] equals "{{#c0.selectedBranch#}}")
  • 发布门禁:variableSelector [s,input] 已声明,value 没被检查 → 0 个 problem。我在 7d29453 上用临时测试复现,输出 publish problems: []。
  • 运行时:c0 的 committed state 是 {"selectedBranch":"kickoff"},resolveWorkflowSelector(["c0","selectedBranch"]) 命中,c1 选到 matched(同样复现)。
  • desktop:WorkflowVariablePool::from_graph 的 declare_base_node_variables 对 Condition 走 _ => {},c0.selectedBranch 是 Undeclared,节点直接失败。

同类入口还有 agent-a.reason(Cloud 的 agent 终态 output 是 {"reason": …},desktop 不声明这个根)以及 sys.* / user-defined globals(desktop 声明,Cloud 不声明;模板选择器同样拦不到)。这违反 #210 的「desktop 报类型错误或未赋值错误的配置,Cloud 必须同样失败或发布期拒绝,不能静默落到 else」,也和 condition_selector_undeclared 注释里「a selector naming a node or root the graph never declares」的表述不符。

建议:把期望值模板里的占位符也解析出来(exclusiveWorkflowTemplate / renderWorkflowTemplate 已有解析逻辑,可拆一个返回 selector 列表的 helper),复用同一份 declaredRoots 校验,field 记 cases.conditions.value;补一条负向用例(模板指向 c0.selectedBranch 或 sys.workflow_id 被拒)。如果决定不修,就得在 specs 里把模板选择器写成残留差异并由 owner 认可,不能继续维持「已对齐」的表述;那样本次的 condition_selector_undeclared 覆盖范围也要在 ADR 里说清楚。

非阻塞

  • 混合模板对整数值 float 的渲染(Cloud canonical 2 / desktop 2.0)仍没有注释或 specs 记录;它会改变字符串比较结果,建议一并写明。
  • workflowDeclaredRoots 对任意非 null outputContract 都声明 structured_output,desktop 只对 Structured 变体声明;今天该字段先被 node_config_unsupported 拒绝,所以不可达,但表述最好与 desktop 对齐。

合并仍由 owner 决定。

Zpc001 and others added 3 commits October 19, 2026 19:42
desktop's WireConditionRule.value is Option<Value> and serde reads a JSON null
as None, so a comparison operator reports a type mismatch instead of comparing
against null. resolveWorkflowExpected now reports a present-but-null `value` as
absent, and a wire-shape test pins the decoded JSON spelling.

Refs ora-space/circus#215

Co-Authored-By: Claude Code <noreply@anthropic.com>
The publish gate checked variableSelector against the graph's declared variable
roots but not the placeholders in an expected-value template, so a template like
{{#c.selectedBranch#}} — a root Cloud's condition node writes at run time but
desktop never declares — passed the gate and then diverged at evaluation. The
gate now parses every placeholder through the same declaredRoots and refuses an
undeclared node or root with condition_selector_undeclared on
cases.conditions.value, covered by a negative publish case.

Refs ora-space/circus#215

Co-Authored-By: Claude Code <noreply@anthropic.com>
A literal `value: null` is the missing operand, but a template that resolves to
null stays a present value desktop can compare (Option<Value>::None versus
Some(Value::Null)). The existing null fix already behaves this way; this test
pins the distinction so a future change to either path cannot collapse it.

Refs ora-space/circus#215

Co-Authored-By: Claude Code <noreply@anthropic.com>

@wanglongan587 wanglongan587 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

结论:Approve(审到 75b02d7)

第三轮的三条意见都在本轮处理了;新增的 57f4538、f5cf621、75b02d7 我按 desktop 的 condition.rs、variable_pool.rs 逐条对过,没有留下会分叉的入口。

核对基准:本 PR head 75b02d7、cloud upstream/main ceb39bc、desktop upstream/main 3fa5fc0。
本地验证:go test ./internal/core/ ./internal/collab/ ./integration/ 通过;gofmt -l 无输出、go vet ./internal/core/ ./internal/collab/ 干净。没有 TEST_DATABASE_URL,DB 用例跳过(与上轮相同);CI check 已通过。

本轮核对

  1. 字面 value: null = 缺失操作数。 resolveWorkflowExpected 对 raw == nil 返回 (nil, false),比较算子走 operands() 的 condition_type_mismatch("a comparison value"),与 desktop Option<Value>::None → TypeMismatch 同一条路径;exists / empty 不读期望值,行为不变。TestWorkflowConditionExplicitNullExpected 用 json.Unmarshal 解码真实 wire 拼写(而不是 Go 字面 nil),TestWorkflowConditionOperators 的 equals null 已改为期望错误。
  2. 字面 null 与模板 null 分得开。 独占模板解析出 null 时仍是 hasExpected=true,对应 desktop 的 Some(Value::Null);TestWorkflowExpectedNullLiteralVersusTemplate 把两种来源钉住,workflowConditionOperand 与 resolveWorkflowExpected 的注释也改成同一结论。
  3. 模板占位符进同一份声明集。 workflowTemplateSelectors 用与 renderWorkflowTemplate 相同的 {{# / #}} 扫描和 . 切分提取选择器,validateConditionRule 对 value 的每个占位符查 declaredRoots,未声明时记 cases.conditions.value;负向用例 {{#c.selectedBranch#}} 覆盖 Condition 不声明任何根,正例 {{#s.input.name#}} 走 Start 声明的根。门禁清单仍是 9 项(双向核对仍成立),全仓没有 condition_value_template_unsupported 残留。
  4. 上轮的非阻塞项(structured_output 的 Structured 条件、混合模板整数值 float 的渲染差异)都已写进注释;前几轮的失败不派发 sibling、Start 去 TrimSpace、simulator 形状没有被本轮改回。

非阻塞

  • TestWorkflowExpectedNullLiteralVersusTemplate 只断言 resolveWorkflowExpected 的 hasExpected,没有断言比较结果;补一行 equals 可以让「模板 null 可比较、字面 null 报错」在行为层也钉住(字面侧已有 TestWorkflowConditionExplicitNullExpected)。
  • workflowTemplateSelectors 对每个出现位置各报一条 problem,同一 value 里重复同一个未声明占位符会得到重复的 condition_selector_undeclared;不影响正确性,前端逐条展示时可以去重。
  • workflowDeclaredRoots 的注释改动跟在了 null 修复 commit 57f4538 里;按 commit 拆分偏好更适合 docs commit,仅记录,不影响合并。

合并仍由 owner 决定。

@EricWvi
EricWvi merged commit 58e5ea3 into ora-space:main Oct 19, 2026
1 check passed
ElephantGit pushed a commit to ElephantGit/cloud that referenced this pull request Oct 26, 2026
…ed deadline

ADR bounded-agent-startup D5 (circus ora-space#92): a run Workspace creation parked in
blocked holds the Project's one_project_operation slot with no public retry, so
Cloud's own scan is the only way the wait ends. The first transaction persisting
blocked now freezes an absolute, never-renewed deadline (config
agent_workspace_blocked_timeout, default 30m, explicit invalid values reject
startup; migration 0035 adds the columns and widens the residual reason to
workspace_unavailable). Legacy blocked creations are not backfilled: the first
policy scan anchors them once as first_observed with one default window.

Expiry settles atomically: the residual ledger, admission fence, model
revocation and discarded queued turns commit together with the business terminal
(failed/done, workspace_unavailable, deliveryState=skipped) and the creation
operation's failure (provisioning_result_unknown), which is what releases the
Project slot for another Workspace. Nothing is fabricated or deleted: no Node
result, no session, no ordinary delete; an accepted provisioning cancel keeps
its record and is settled by the same abandonment; a creation that really
settled first keeps its original outcome. A ledger write failure rolls the whole
transaction back, so the slot is never released without residual ownership.
ElephantGit pushed a commit to ElephantGit/cloud that referenced this pull request Oct 26, 2026
ADR bounded-agent-startup D5 (circus ora-space#92): after Cloud abandons a blocked
creation, permits issued before the deadline, in-flight Substrate ensures and
work a Node already accepted can still complete. The operation's own actions
are closed to a failed operation by design, so those facts need an independent
takeover entry that never grants new dispatch rights.

RecordProvisioningFact on WorkspaceOperationService (proto v1, additive; buf
breaking clean) accepts one fact per call under the caller's current lease:
an effect's real Substrate result (through effectResult's own evidence rules),
a clone or plugin execution's real outcome (through cloneResult and
nodeExecutionResult), or the creation's own real outcome. Facts are validated
against the original operation/effect/execution identities, stored
idempotently on the original records, and receipted onto the residual ledger's
lateFacts before the response acknowledges; contradicting facts conflict and
nothing reopens: no admission, no starting, no second settlement.

The new deadline columns stay out of the public run resource shape
(stripAgentRunSkeleton), keeping the OpenAPI contract byte-identical.
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