This repository has been archived on 2026-09-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
BPOS-TMP-REPO/apps/api/src/modules/loop-engine/CONTRACT.md
T
xcsandClaude Fable 5 c4219a7b7d feat(w05+platform): 确认闸条件档 gateWhen —— 类型不一致不许确认;量高改可缩;卡间互换动画
2026-08-31 用户三连:

① 「提示两颗料不是同一个芯片类型…就不应该能写入并继续下一步」——
  缺格闸只数必填空不空,管不了跨行一致性。平台加**确认闸条件档**:配方 card 声明
  gateWhen(S-FEEL,只读基座读数)+ gateWhenSay,受理回传前在 submitGate 现算
  (rows/missing/tally/distinct 全套读数按声明表现读),为假 400、拒回用发卡人那句话;
  装包静态审与动作级 when 同一套审;评估不出的条件不成墙(坏表达式装包就拦)。
  W05 钉 parts_profile_distinct_chip_type_id <= 1 —— 钉 profile 本表(草稿现读零滞后;
  钉 sub_eval.part 那份要等配方 tick,人存完就点确认会被误拦),profile.chip_type_id
  加 distinct 声明。e2e 案例 2 的空手探针从此吃的就是这道闸。

② 「页面底部没东西还能向下滑一截」—— ViewContainer 量高从
  max(documentElement.scrollHeight, body) 改 body 优先:documentElement 以 iframe 自身
  高度为下限,内容缩回后它缩不回去,量高**只涨不缩**(参数编辑态把卡背拉长过一次,
  底部从此留一截空白滚动)。body(margin 0)才是内容自己的高。

③ 「两张卡片中间做出可替换的动画」—— 单点单向信号换成**对向双信号点**(左右互穿,
  走在卡后、只在缝里现身)+ 中央 ⇄ 徽轻呼吸 —— 这界面是判「可否替换」的,动画说的
  就是这件事。reduced-motion 全静。

测:契约 gateWhen 解析 + runner 透传(变异红)+ conditionGateOf 3 例(含坏表达式不成墙)
+ steps/activity 锚;2531 全仓四门绿;2.12.0 装库核过(gateWhen 入库/swapfx);
:8787 已重建重启。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-31 17:43:08 +08:00

19 KiB

module: loop-engine

职责: loop 范式:CMMN 子集解析、标准生命周期推进、前提求值与 Enabled 集发布、 DMN 确定性求值与命中行留痕、双向驱动(前推 × 后拉 × 交集导航)、 Agent 回路的回合驱动(三型行动) 不负责: Agent 的推理本身(经 AgentPort,Mastra 在 app/adapter)、放行与落地(见 release)、 终态命名(见 task)、活动记忆的存取(经 MemoryRecorder)

公开出口: index.ts

  • 类型:LoopEngineService CaseModel CaseRuntime EnabledSet Navigation AgentAction TurnOutcome DecisionResult DecisionTrace Gap ItemState CaseState
  • 错误:CmmnParseError IllegalTransitionError ForbiddenTerminalError AutomaticOnWorldTouchingError
  • 端口类型:LoopStateStore Sfeel TaskDataGateway(含 writable 授权面)AgentPort ToolGateway CardGateway ListenerRegistrar MemoryRecorder TaskContext AnalyzeGateway
  • 用例工厂:createTurnRunner(deps): LoopEngineService
  • 纯函数:parseCmmn parseXml publishEnabled navigate evaluateCriterion transitionItem occur achieveMilestones reenter autoCompleteReady canTransitionItem mayCompleteCase mayTerminateCase evaluateTable evaluationOrder deduceGaps
  • adapter:createLoopStateStore;Drizzle 表:loopStateTable

层级: L5(编码规范 §4) 依赖: @contracts;@shared/db;skill-engine(L4);port 注入:@sfeel / ledger / toolbox / human-tool / event / memory 被依赖: task

不变量(AM 04 / 12.2):

  • CMMN 子集封顶:parseCmmn 遇到表外构件直接报错,不静默忽略 —— 扩构件 = 改编码规范 §4 那一行 + 建 ADR
  • 语义忠实:迁移拓扑写死在 ITEM_TRANSITIONS / CASE_TRANSITIONS, 引擎不得私改;活动与模型不得发明状态
  • Enabled ≠ Active:引擎推进到 Enabled 为止;进入 Active 永远出自 Agent 的 manualStart 并留痕
  • 引擎零 LLM:前提求值与 DMN 都走 @sfeel;语义性前提标「待裁量评估」交上层, 引擎不代裁量、不代把关
  • 能算的不问 LLM:DMN 输入齐备即确定性求值,同输入必同结论;命中规则行必留痕
  • 结论不可静默覆写:isBindingConclusion 标出约束性输入
  • 三型行动:每步行动属于且仅属于「调工具 / 调人 / 立监听」之一; 调人 = 立监听听人;AI 对世界只有触达(动作,落在放行)与等待(监听)两种姿势
  • 一拍一串行动(2026-07-30,AgentTurn.actions,封顶 TURN_ACTION_CAP = 6): 拍是按模型调用计费的 —— 一拍只许一个行动时,每个 write / finish / start 都单烧一遍 完整上下文,实测 A1 六步的活恰好 21 拍(其中 19 拍是该约束下的最短路径)。 收成一串后同一单 8 拍收口、token 减半。执行纪律引擎强制:按序执行,谁被拒停在谁那儿 (前缀已生效不回滚,理由喂回下一拍);tool 之后截断(出参没看见,后面的行动是 闭着眼计划的);挂起型(ask/human/listen/submit)落地即收拍;超封顶丢弃并留痕。
  • 写台账草稿是过程写的唯一形态(2026-07-30 新增 write 行动,铁律②)。 从前这条路根本不存在:TaskDataGateway.draftWrite 有端口无调用方, Agent 干出来的结论一处也放不下 —— 后果不是少个功能,而是必交付物恒零行、 验收判据求值取不到值、出口载荷推不出字段:跑得再热闹,放行那刻什么也交不出。
    • 一拍可写多行:一份 BOM 一次写完,不一行一拍各烧一次模型;
    • 地址与列名的把关在装配层(它认识活动清单)—— 未声明的地址与列一律拒写, 越域写与越权调工具同罪;拒了抛错,由回合收成「行动被拒」喂回下一拍,引擎不崩;
    • 写的授权面是台账声明,不是计划项(2026-07-30):itemId 只是记账, 挂得上就挂(项活性化),挂不上(已完成 / 阶段 / 不存在)照写不拦, 留痕注明「未挂到计划项」。从前绑死在项生命周期上,死锁过一单:S5 收完才发现打标 没写全,所有能挂的项都 Completed —— 每拍补写都被拒,连拒五拍熔断,而数据本身合规;
    • 写完把该项收了(未 Active 的就地过一下 Active)。
  • 人工激活的项:模型显式动它 = 启动它(2026-08-02 拆墙,readyToWork)。 从前它要求先发显式 start,不发就拒;提示词与拒回都写全之后实测仍熔断 (A1 一单连拒五拍,五次全是「S3_confirm 要先 start」)—— 那道墙保护不了任何东西: start 是模型自己的动作,同一串连发即过,拦下只是逼它多演一步学不稳的礼仪。 人工激活真正的分量原样保留:引擎不因哨兵满足替它启动(哨兵只推到 Available/Enabled),人答卡之前不给 doneWhen 替人收。start 仍是合法行动,只是可省。
  • 人工激活的项,人答过卡之后照常求 doneWhen(2026-08-27 晚,用户:「根因修了吗?不要只修表象」): 自动收步对人工激活项的门槛是 worldTouched(卡真发过)且此刻没有未答的卡 —— 卡没发过的 doneWhen 再真也不收(那才是替人签字);答过之后「办完了没有」若是基座数得出来的事实 (W05 两张数据工单:人填的值已入库、必填不缺),引擎自己收,不再指望模型记得 finish (剧本两处写死「先 finish 再发下一张」,模型照样忘,两步同时 Active、件停在上一步)。 拍首扫一遍,拍尾再扫一遍(人答卡那一拍里模型写完东西 doneWhen 才真;这一拍刚发卡的项排除; 提交放行那一拍不扫,免得把在途验收信封擦掉)。没声明 doneWhen 的人工激活项一个字不变。
  • 人填的表模型不许写(2026-08-28):剧本 PlanStep.humanWrites 声明的台账地址,write 当场拒并指路 「去发那张数据工单,值由人填」;drop 不拦(写错了得撤得掉)。判法认行动点名的项,点不出退回当下在办的那一步。 起因:P-10 资料记录那三格只可能从人那儿来,而模型把台账里原 PN 那一行的规格书逐字抄到了候选料号头上 —— 行找对了、料找错了,这份假证据会喂进差异比对当「硬约束有证据支持」。只声明「模型永远没有来源」的表。
  • 还欠一张卡的那一步,提示词不摆 doneWhen(同日):判据同时也是摆在模型眼前的一个目标,后面还跟着 「成立了就 finish」—— 而人工激活的项在卡发出去之前根本不许 finish。同一份提示词里两句话打架,实测它选了判据那句、 去凑数据,然后 finish 连撞五拍墙熔断。卡发出去、人答过之后判据照常摆。
  • 画面跟着这一拍发出去的卡走(同日):human / entry / decide 把「卡是哪一步的」交回拍内, 那一步声明了画面就推它,压过「第一步没干完的」—— 卡是发给人的那件事,件必须是那件事的件。
  • auto 步不问模型(2026-08-29,turn-runner 拍首接线 createAutoRunner):PlanStep.auto 存在时, 拍首先跑配方;发了卡 / submit 就收拍(零 token)。有 guide 的照常问模型(半 auto); 两者都没有且 doneWhen 不成立 → 交人(配方没做到位,是包的 bug)。
  • 有未答的卡在场时,配方只跑 project / say —— 只算、只写,不发新卡、不 submit、不调 AI。
  • 配方每一笔之前刷新任务视图:后一笔常要数前一笔刚写下的行,不刷新就恒为 0。
  • 里程碑前提达成即达成(2026-07-30,achieveMilestones):CMMN 的里程碑没有执行体, 也没有谁去按它。从前只有 onEvent(ref = 里程碑 id) 会 occur,而系统里没有任何东西 发这种事件 —— 于是里程碑永远 Available:既是必做项(堵死 autoComplete), 又常驻「当下可为」与「目标缺口」,让 Agent 每拍都看见自己按不动的东西。 现在引擎在发布前自己收(达成留痕);无前提的里程碑不自达成 —— 那是建模漏了前提。
  • 事件监听器不是交付物,是等待岗(2026-07-30):不进 gaps,也不算 autoComplete 的必做项 (从前只排除 timerEventListener,一个 userEventListener 就让案子永远判不齐)。
  • 一拍 = 一个行动 + 一句交代(2026-07-30 AgentTurn)。Agent 从前不会说话 —— 会写台账、调工具、调人、立监听,但没有一型行动是「对人说一句」。 实测:A1 跑十几拍、写七行台账、调两次工具,负责人的对话区从头到尾空白, 他只看得见「多了一个任务」。「人只在责任点与放行点出现」不等于「过程中人什么都看不见」。
    • 交代落 narration(它进对话区),与 thought(给自己留的痕)分开;
    • 不是一型行动,是每一拍的附带:先做成 kind:'say' 试过 —— 它占一整拍又不推进局面, 预算下的 Agent 理所当然跳过它,对话区照旧空白。让说话与干活抢拍, 等于把「必须说」设计成了「有空才说」;
    • 先交代再动手:人看到的是「我要去查这三个 PN 的历史价」,紧接着才是结果;
    • say.view 可顺手换局面模版 —— 局面变了,人既要听到为什么,也要看到新画面。
  • 卡死了交回给人(2026-07-30 stalledTurns + STALL_LIMIT=5)。预算会烧完, 但那是二三十万 token 之后的事;这期间界面上只是「进行中」一动不动 —— 那不是在办,是卡死了。 连续 5 拍行动被拒就不再问模型(再问也是同一个答案),发执行卡把拒绝理由原话摆给人: 是它跑偏、是行动型少了一种、还是案例模型建得不对 —— 三种处置完全不同,人得看得见证据。
  • 补全卡是对话卡,不是执行工单(ask 行动 / CardGateway.ask)。 「按缺口清单追问补全」要的是:缺口清单摆在对话区、带一张表单,人就地填齐。 从前 Agent 发不出对话卡(只能选执行 / 验收两种工单),SOP 上画着补全卡的那一步 只能拿执行工单顶 —— 形似而神非:派一张工单去问几个字段,收卡的人得进待处理、 办完再回来,重得不成比例。一个格子都没有即拒发(空表单等于没问)。
  • 决策卡也是对话卡(2026-07-31 decide 行动 / CardGateway.decide)。 与补全卡的分工:补全卡问事实(缺哪几个字段,你填);决策卡问处置(往哪走,你选)。 从前决策卡只有 UI 没有 Agent 侧 —— 卡面在对话区画得出来,行动型里却发不出它, 于是所有岔路口都被写成补全卡:人拿到一张要自己敲字的表单,而不是两个可点的选项。 少于两个选项即拒发(一个选项的「选择题」是通知,不是决策)。
  • 视图推送回报推没推上(ViewGateway.push → {pushed, templates})。 从前引擎每拍推一个硬编码的 case-overview,而活动声明的模版未必叫这个名字 (A1 的四张一个都不叫)—— 模版查不到就静默 return,每一帧都被悄悄丢掉, 工作区从头到尾空白,日志里连一个字都没有。静默失败让「没画面」看起来像「没产出」。 现在:要的那张没有就退到活动声明的第一张,换不上则如实记痕。
  • 人答了什么要看得见:MemoryRecorder.window 带回 detail,回传值(detail.values) 随轨迹进叙事面。不给的话「执行工单回传」这一行读起来像事情办完了,而人填了哪几个值 Agent 一个字看不见 —— 既写不进台账,也只能再问一遍。
  • 终局唯放行与 Harness:引擎不把案计划置 Completed —— 那是 release 的事; mayCompleteCase 只对 release 为真,mayTerminateCase 只对 harness / owner / governance 为真
  • 提交放行 = 发验收卡(2026-07-28 三修①):submit / 判齐(autoCompleteReady) 不再返回 submitted 空转 —— 发验收卡、立监听、在途验收入状态信封 (loop_state.pending,迁移 0015);判齐重入不发第二张卡;裁决到达后由下一拍分岔: pass → finished + released: true(选口随放行带回);非 pass = 打回重做, 不是终局(2026-08-22 修死:从前非 pass 也走 finished 分支,task 层把 「finished 未放行」重新挂成 awaiting-human —— 人已经说了「重做」,却再没有事件 叫醒它,一单就此卡死)——意见(PendingAcceptance.comment,onEvent 从裁决载荷 捕入信封)以「验收被打回重做:…」进轨迹,在途验收清掉,同一拍照常问模型; 刚被打回的那一拍不走判齐快道(否则判齐恒真的活动会在意见没人读时原样再发一张卡), 模型修订后重新 submit,新发的验收卡才是下一次裁决的对象
  • onEvent 只记不跑(三修②):事件到达只登记 occur / 收裁决入信封,存快照即返; 推进(可能含一次模型决策)统一发生在 tick —— steps/activeMs/tokens 一个预算口径
  • 任务轨迹喂回叙事面(三修③):memory.window(taskId) 的最近轨迹进 【本任务最近轨迹】段,窗口大小 = 活动声明的 harness.memoryWindow,Agent 每拍不失忆
  • 驳回是迁移不是状态:reenter 以 repetitionRule 新实例重新入场
  • 打回重做之后,没改过东西就不许再提交(2026-08-23):raiseAcceptance 一处把关, 模型 submit 与判齐快道两条路共用 —— 打回之后台账没动过(写/撤都算)就拒; 软硬两段:头一次拒并指路(改台账 / 发卡问清楚要改什么),已因此拒过一次再提交就放行 (墙要配路,不锁死单子)。起因:打回那一刻案例模型的项常常全是 Completed, 模型除了原样重新 submit 无事可做,6 秒就把同一份成果递回来
  • 立监听的授权面是活动清单的 declarations.listeners(2026-08-23): ListenerRegistrar.declared(taskId) 给出声明过的那几种,没声明过的类型当场拒。 与「写的授权面是台账声明」「调工具的授权面是白名单」同一条 —— 立了没人叫醒的监听不是等待,是失联(实测:模型在依赖断点上临场立 data 监听, 而那个活动一个监听都没声明,单子永久挂在 awaiting-event)

近变(2026-08-27 傍晚):叫了人就等人 —— runTurn 在途验收之后、判齐快道之前先问 cards.unanswered(taskId): 这一单还有卡没交回(工单没办结 / 对话卡没作答),这一拍不问模型,留一条内部痕、状态仍是 awaiting。 不管是谁叫醒的(迟到的回传事件、另一台后端的推进器、手动 wake)都推不动它。起因:S1 的卡发出 0.3 秒后, 上一张卡回传的事件才投到,再一拍模型照事实行「已经发过卡,人已经被问过了」把没人答的 S1 收掉了。 cards.decide 另多一项可选 variant(引擎按剧本定面孔,点名的项挂不到就按当下在办的那一步)与 checklist (剧本 acceptanceChecklist,人工验收清单,与面孔同一条路带上卡);终局验收卡的清单由组装层在 submit 时从 剧本里取(声明了清单且不是审核卡的那一步)。

近变(2026-08-27):cards.issue 的入参多一项可选 itemId(这张卡挂在哪个计划项上)—— 组装层据此查剧本声明 (PlanStep.cardVariant:这一步派的是数据工单),不靠模型抄卡骨架(实测它会把 variant 抄丢)。 引擎自己发的卡(验收 / 兜底)没有项,不传。

近变(2026-08-27):cards.decide 同样多一项可选 itemId(cardVariant: 'review' = 审核卡摆成验收工单的样子,机制仍是决策卡); 决策卡的回答进基座读数 decision_last / decision_<值>_total(按 card-reply 数)—— 审核通过与否不再由模型自写的旗说了算; 拒回 / 观察 / 事实行按答案分岔(chosenGuidance):custom: 不是通过,不许写通过旗、不许 finish。

近变(2026-08-29,终审修复波):TaskDataGateway 两处扩面,都是配方专用,模型那一路一个字不变 ——

  • draftWrite 多两个可选入参与一个可选出参:
    • skipUnchanged —— 值没变就不落笔。DraftStore.put 的 upsert 尾巴上有 landed_at = null (「再写一笔就不再是已落地」),而配方每一拍都把同一张表重投一遍:照写会把数据工单确认入库的那些行 悄悄 un-land,整单放行时再写一遍正式表、再抛一遍 data 事件 —— 而对 landed 行的承诺是 「整单放行不再写它、不抛事件」。模型不传这个旗:它的「空转写」要被看见(如实回 changed:false)。
    • skipExisting —— 「只建不改」(createOnly)的表碰到已在正式表里的键,回 {changed:false, skipped:true} 而不是抛。那句拒回是写给模型看的(「别再写它,用 drop 把骨架撤掉」),摆到人面前毫无意义; 而配方抛出去就是一单永久停在那儿。对配方来说「这颗料已经在库了」是常态,不是错。 跳过的行照旧算「这一笔要的行」,prune 不许撤它。
  • writable 多一个可选 caseKey(单标识列):摆给人看的行标识要去掉单号那一段 (2026-08-25 那条口径,基座那一侧是 rowLabel)—— 人正在这一单里看,单号是脚下的地面。
  • draftDrop 多一个可选入参 skipLanded 与可选出参 landed(2026-08-31,同族第三个旗): 已随「数据工单确认即入库」落地的行,配方 prune 传 skipLanded 时回 {dropped:false, landed:true} 而不是抛 —— 已落地 = 已是世界的一部分,不是陈旧草稿,跳过是配方的常态;抛出去就是整单停摆 (2026-08-30 e2e 案例 2 的死锁,当时以「W05 ②③ 两笔不带 prune」绕开,代价是没落地的 陈旧行也不清 —— 2026-08-31 用户实测点名的「改回同一类型,下面还是两个类型」)。 模型那一路不传:那句「已入库撤不掉」的拒回是它的行动指南,一个字不变。
  • 配方 card 多两个可选键 gateWhen / gateWhenSay(2026-08-31):确认闸的条件档 —— 除了「必填缺几格」,发卡的人还可声明一条 S-FEEL 条件(只读基座读数,装包静态审同 when 一套审),受理回传前在 app 层 submitGate 现算,为假不受理、拒回用发卡人那句话 (W05 第一例:两颗料不是同一个芯片类型不许确认)。运行时评估不出的条件不成墙。

同批三条不变量:

  • 配方由 liveStep 驱动,不由 current 的兜底驱动:兜底(在场的第一个有剧本的项)不过滤 settledIds,它只服务提示词;让它驱动副作用 = 已收掉的步再写一遍台账、再 prune 一遍、甚至再发一张卡。
  • 配方的 submit 与模型的 submit 同一条处置:raiseAcceptance 抛了不许让回合崩(留痕 + running)—— 裸着调的话,engine.advance / task-service.tick / task-advancer 一路都不捕获,任务留在 running, 推进器无限热循环:不烧预算、不换状态、界面纹丝不动。重做闸的「改过台账」也认配方的写 (observation + detail.auto + detail.ledger),否则那堵墙对配方永远开不了。
  • 配方办不下去 = 发一张卡交人,不是 awaiting-event 干等:推进器只捡 running 的单, 卡已答完、没立监听、没有时限时再没有任何东西会叫醒它,而界面写着「等外部回音」。