Repository navigation
feat(core): evaluate conditions with desktop's semantics - #92
Conversation
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>
162187e to
606949e
Compare
wanglongan587
left a comment
There was a problem hiding this comment.
结论
请求修改。四类求值语义我逐项对过 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 一致的部分
contains:字符串按子串,数组按元素相等,object/标量走类型错误 —— 分别对应 desktopcontains()返回Some/None的两条分支(condition.rsstring_operator_on_object_fails_as_type_mismatch、string_operators_match_on_text)。starts_with/ends_with:两侧都必须是字符串,数值不再是文本比较;desktopstarts_with/ends_with的as_str()?行为一致。表里{"starts_with non-string actual/expected is a type error"}覆盖到位(不在三个 parity 测试里,见 specs 侧意见)。- 期望值模板:独占
{{#…#}}保留绑定 JSON 类型、混合渲染成字符串、字符串原样/其余按 JSON 渲染(键序一致,因为encoding/json与 serde_json 都排序)、未赋值/未闭合/畸形选择器报错,和resolve_comparison_value+render_variable_template逐条对上。exists/empty也照样先解析期望值,与 desktop 在evaluate_rule里无条件解析一致。 - 失败传播:
condition无法求值时提交failed+reason,workflowRunFailureReason按图顺序取第一个失败节点的reason作为运行 error;TestWorkflowAdvanceConditionNotContainsOnAbsentVariable断言运行 failed、节点 failed、error 里点名s.never_written。or用例先求全部 rule 再合并,不会把后面未赋值的 rule 短路掉 —— 与 desktop「先 collect 全部 Result 再 all/any」一致。 - 错误顺序:先解析实际值再解析期望值,与
evaluate_rule同序;根存在但嵌套路径缺失/非对象报错,对应 desktopPathMissing/PathNotObject也报错。 - 门禁只删一项:
workflowUnsupportedConditionConfigs9 → 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 先同意)。
建议与小问题
-
失败后同一 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 },让失败当轮就结算。 -
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。只影响报错文案,改注释或改实现都行,但目前注释是错的。 -
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"。顶层数字比较两边一致。 -
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 的写法把「用编辑器里的默认提示词」写清楚了,这点没有问题。
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>
|
已按检视意见修改,逐条回复。 处置 1:未声明选择器选 (a),进发布门禁新增 反例覆盖两半:
门禁只作用于新的发布;历史快照维持运行时「缺失」语义,已写进 specs 的 ADR(specs#133)。 处置 2:头注释改为 Cloud 实际产出的根
建议 3:失败后不再派发 sibling
建议 4 / 5:注释
建议 6:Start 输出
验证
请再看一遍。 |
|
@wanglongan587 已按检视意见推送修复(head |
wanglongan587
left a comment
There was a problem hiding this comment.
复审结论: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
left a comment
There was a problem hiding this comment.
复审补充: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/ desktop2.0)仍没有注释或 specs 记录;它会改变字符串比较结果,建议一并写明。 workflowDeclaredRoots对任意非 nulloutputContract都声明structured_output,desktop 只对Structured变体声明;今天该字段先被node_config_unsupported拒绝,所以不可达,但表述最好与 desktop 对齐。
合并仍由 owner 决定。
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
left a comment
There was a problem hiding this comment.
结论: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 已通过。
本轮核对
- 字面
value: null= 缺失操作数。resolveWorkflowExpected对raw == nil返回(nil, false),比较算子走operands()的condition_type_mismatch("a comparison value"),与 desktopOption<Value>::None→TypeMismatch同一条路径;exists/empty不读期望值,行为不变。TestWorkflowConditionExplicitNullExpected用json.Unmarshal解码真实 wire 拼写(而不是 Go 字面 nil),TestWorkflowConditionOperators的equals null已改为期望错误。 - 字面 null 与模板 null 分得开。 独占模板解析出 null 时仍是
hasExpected=true,对应 desktop 的Some(Value::Null);TestWorkflowExpectedNullLiteralVersusTemplate把两种来源钉住,workflowConditionOperand与resolveWorkflowExpected的注释也改成同一结论。 - 模板占位符进同一份声明集。
workflowTemplateSelectors用与renderWorkflowTemplate相同的{{#/#}}扫描和.切分提取选择器,validateConditionRule对value的每个占位符查declaredRoots,未声明时记cases.conditions.value;负向用例{{#c.selectedBranch#}}覆盖 Condition 不声明任何根,正例{{#s.input.name#}}走 Start 声明的根。门禁清单仍是 9 项(双向核对仍成立),全仓没有condition_value_template_unsupported残留。 - 上轮的非阻塞项(
structured_output的 Structured 条件、混合模板整数值 float 的渲染差异)都已写进注释;前几轮的失败不派发 sibling、Start 去TrimSpace、simulator 形状没有被本轮改回。
非阻塞
TestWorkflowExpectedNullLiteralVersusTemplate只断言resolveWorkflowExpected的hasExpected,没有断言比较结果;补一行equals可以让「模板 null 可比较、字面 null 报错」在行为层也钉住(字面侧已有TestWorkflowConditionExplicitNullExpected)。workflowTemplateSelectors对每个出现位置各报一条 problem,同一value里重复同一个未声明占位符会得到重复的condition_selector_undeclared;不影响正确性,前端逐条展示时可以去重。workflowDeclaredRoots的注释改动跟在了 null 修复 commit57f4538里;按 commit 拆分偏好更适合 docs commit,仅记录,不影响合并。
合并仍由 owner 决定。
…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.
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.
本 PR 做什么
按 desktop
engine/condition.rs(参考实现)补齐 Cloud 的 condition 求值语义,关掉 ora-space/circus#210 的四项差异。此前发布门禁对这些写法的拒绝是「天花板」而非「已对齐」——数组/对象contains、非字符串starts_with/ends_with、期望值模板、选择器取值这四项直接不可用。现在:contains:字符串做子串匹配;数组做元素相等;其余类型算类型不匹配。starts_with/ends_with:两侧都要求字符串。数值比较要求两边都是数。{{#node.var#}}:按 Dify 风格解析。独占占位符保留绑定 JSON 类型,混合模板渲染成字符串,未设置或畸形的选择器让节点失败,而非拿占位符文本去比。字面
"value": null按 desktop 的Option<Value>读作缺失操作数(比较算子报类型错误);独占模板解析出null仍是可比较的现值。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