diff --git a/api-report/apps/api/src/modules/process/index.d.ts b/api-report/apps/api/src/modules/process/index.d.ts index c247331..1736f37 100644 --- a/api-report/apps/api/src/modules/process/index.d.ts +++ b/api-report/apps/api/src/modules/process/index.d.ts @@ -13,3 +13,5 @@ export { createProcessStore } from './internal/adapter/process-store.ts'; export { nodeInstanceTable, processPackageTable, workflowTable } from './internal/adapter/process-schema.ts'; export type { CompiledFlow, CompileHooks, RunResult } from './internal/adapter/workflow-compiler.ts'; export { compilePackage } from './internal/adapter/workflow-compiler.ts'; +export type { MastraRunner, MastraRuntimeDeps } from './internal/adapter/mastra-runtime.ts'; +export { createMastraRuntime } from './internal/adapter/mastra-runtime.ts'; diff --git a/apps/api/src/modules/process/CONTRACT.md b/apps/api/src/modules/process/CONTRACT.md index 522d905..71ef2dc 100644 --- a/apps/api/src/modules/process/CONTRACT.md +++ b/apps/api/src/modules/process/CONTRACT.md @@ -108,8 +108,21 @@ refreshState / terminate)。执行面的语义、留痕与副作用编排整体 - 编译期全验:悬空边、正向边成环(未标 reopen)一律当场报 —— 编译只走到得了的边, 悬空边可能落在递归碰不到的角落。 -替换路径:`ProcessServiceDeps.runtime` 注入第二实现(胶合层 = `FlowRuntime` 的 -Mastra 实现,尚欠 —— 见 debt M5-1)。八例端到端是两个实现的**等价性护栏**。 +**胶合层**(`internal/adapter/mastra-runtime.ts`,出口 `createMastraRuntime`)= +`FlowRuntime` 的 Mastra 实现,与自持状态机对等。它管的是「一单一单的账」: + - **发号权在主工程**:实例号 `nextInstanceNumber` 算、`sys.node_instance` 落笔 + (Mastra 不发实例号也不发任务号); + - **run ↔ 实例**:`runId = #`,重开 = 新 run(seq+1),旧 run 冻结; + 当前 run 的入口记在工作流 `vars.__runEntry`; + - **迟到 / 重复回调不甩给 Mastra**:先在实例表定位、确认是当前 open 的那个, + 不是就发 `stale-callback` 事件丢弃(错投的 resume 会打坏 run); + - **失败走向不进 Mastra**:改道/升审批/容忍都当作「从目标节点重新入场」—— + 起一个以它为入口的新 run,绕开 `.parallel()` 那套连坐语义; + - 状态仍是**聚合投影**:`refreshState` 读 `sys.node_instance`,不读 Mastra 快照。 + +替换路径:`ProcessServiceDeps.runtime` 注入 `createMastraRuntime({..., runner })`; +`MastraRunner` 是 Mastra 侧的最小口子(startRun / resumeRun),换版本只改它。 +八例端到端是两个实现的**等价性护栏**;接线开关与真跑验证见 debt M5-1。 仍须绕的语义冲突:`.parallel()` 任一失败即整体失败(step 内自吞,不让 Mastra 感知)、 三态聚合投影(abnormal 防假成功,Mastra run 无此态)、`node_instance` 投影表必须照写(治理台按行查、实例发号权仍在主工程)。 diff --git a/apps/api/src/modules/process/index.ts b/apps/api/src/modules/process/index.ts index 6f70db3..4594452 100644 --- a/apps/api/src/modules/process/index.ts +++ b/apps/api/src/modules/process/index.ts @@ -62,3 +62,5 @@ export { nodeInstanceTable, processPackageTable, workflowTable } from './interna /* Mastra 编译器(M5-1,路线 B:回边成出口、胶合层起新 run)—— adapter 出口 */ export type { CompiledFlow, CompileHooks, RunResult } from './internal/adapter/workflow-compiler.ts'; export { compilePackage } from './internal/adapter/workflow-compiler.ts'; +export type { MastraRunner, MastraRuntimeDeps } from './internal/adapter/mastra-runtime.ts'; +export { createMastraRuntime } from './internal/adapter/mastra-runtime.ts'; diff --git a/apps/api/src/modules/process/internal/adapter/mastra-runtime.ts b/apps/api/src/modules/process/internal/adapter/mastra-runtime.ts new file mode 100644 index 0000000..2903612 --- /dev/null +++ b/apps/api/src/modules/process/internal/adapter/mastra-runtime.ts @@ -0,0 +1,461 @@ +/** + * `FlowRuntime` 的 **Mastra 实现**(胶合层)—— M5-1 的第二实现,与自持状态机对等。 + * + * 编译产物(`workflow-compiler.ts`)只管推图;**这里管一单一单的账**: + * · **发号权在主工程**:实例号由 `nextInstanceNumber` 算、`sys.node_instance` 落笔 —— + * Mastra 不发实例号也不发任务号(spike 实测); + * · **run ↔ 实例**:一个 run 就是一次「从入口走到底」;重开 = **新 run** + * (`runId = #`),旧 run 快照冻结 —— 这正是路线 B 的收益; + * · **迟到 / 重复回调不甩给 Mastra**:先按 taskId 在实例表定位、确认是当前 open 的那个, + * 不是就丢弃(spike 实测:错投的 resume 在嵌套下会把整条 run 打成不可恢复的 failed); + * · 状态仍是**聚合投影**:`refreshState` 读 `sys.node_instance`,不读 Mastra 快照 —— + * Mastra run 只有 running/suspended/success/failed,没有「收敛未到终点 = abnormal」这一态。 + * + * 事件面照旧全发(workflow-started / task-started / route-dead-end …)。 + */ +import type { ProcessPackage, StaffId, WorkflowState } from '@contracts'; +import { PositionVacantError } from '../../contract.ts'; +import type { + ApprovalGateway, + Clock, + FlowEventPublisher, + FlowRuntime, + OrgDirectory, + ProcessStore, + ReceiptNotifier, + Sfeel, + TaskGateway, + WorkflowRecord, +} from '../../port.ts'; +import { aggregateWorkflowState, isDigested, nextInstanceNumber, resolveOwner } from '../domain/owner-resolution.ts'; +import type { NodeInstance } from '../domain/owner-resolution.ts'; +import { assemble, rejectRouteOf } from '../domain/routing.ts'; +import { compilePackage } from './workflow-compiler.ts'; +import type { CompiledFlow, RunResult } from './workflow-compiler.ts'; + +/** Mastra 侧只要这一点点能力 —— 换版本时改这一处,不动胶合逻辑 */ +export type MastraRunner = { + /** 起一个 run(带确定性 runId);已存在且未完结时**不许重跑**(spike B-坑-1) */ + startRun(input: { + workflow: unknown; + runId: string; + resourceId: string; + inputData: Record; + }): Promise<{ status: string; result?: RunResult }>; + /** 推进一个挂起的 run */ + resumeRun(input: { + workflow: unknown; + runId: string; + step: string; + resumeData: Record; + }): Promise<{ status: string; result?: RunResult }>; +}; + +export type MastraRuntimeDeps = { + readonly store: ProcessStore; + readonly org: OrgDirectory; + readonly tasks: TaskGateway; + readonly approvals: ApprovalGateway; + readonly sfeel: Sfeel; + readonly events: FlowEventPublisher; + readonly receipts: ReceiptNotifier; + readonly clock: Clock; + readonly runner: MastraRunner; +}; + +export function createMastraRuntime(deps: MastraRuntimeDeps): FlowRuntime { + /** 编译缓存:一份 (包id, 版本) 编一次 —— 激活那一刻的定义此后不可变 */ + const compiled = new Map(); + + async function requireWorkflow(id: string): Promise { + const record = await deps.store.getWorkflow(id); + if (record === undefined) throw new Error(`工作流不存在:${id}`); + return record; + } + + async function requirePackage(id: string, version?: number): Promise { + const pkg = await deps.store.getPackage(id, version); + if (pkg === undefined) throw new Error(`流程包不存在:${id}`); + return pkg; + } + + function scopeOf(record: WorkflowRecord) { + return { ports: record.ports, vars: record.vars, initiation: record.initiation }; + } + + /** 谁来干:运营时分配(员工组轮值 / 全员轮值)—— 与自持实现同一口径 */ + async function assign(record: WorkflowRecord, nodeId: string): Promise { + const seen = new Map(); + for (const position of await deps.org.positions()) { + for (const c of await deps.org.candidates(position)) if (!seen.has(c.staffId)) seen.set(c.staffId, c); + } + const everyone = [...seen.values()]; + const group = new Set(Object.values(record.workGroup ?? {})); + const groupPool = everyone.filter((c) => group.has(c.staffId)); + const pool = groupPool.length > 0 ? groupPool : everyone; + const spec = { position: groupPool.length > 0 ? '(员工组轮值)' : '(全员轮值)', strategy: 'sole' as const }; + const resolution = resolveOwner(spec, { candidates: pool }); + if (resolution.kind === 'vacant') throw new PositionVacantError(spec.position); + await deps.events.publish({ + workflowId: record.id, + kind: 'owner-resolved', + detail: { nodeId, position: spec.position, staffId: resolution.staffId, by: resolution.by, rationale: resolution.rationale }, + }); + return resolution.staffId; + } + + /** + * 编译(带缓存):一份 (包id, 版本) 编一次。 + * 钩子里的每一步都**现取**当前工作流记录 —— 编译产物在多单之间共用, + * 闭包里绝不能锁住某一单的快照。 + */ + function flowsFor(pkg: ProcessPackage): CompiledFlow[] { + const key = `${pkg.id}@${pkg.version}`; + const hit = compiled.get(key); + if (hit !== undefined) return hit; + + const flows = compilePackage(pkg, { + startTask: async ({ workflowId, nodeId, node, payload }) => { + const live = await requireWorkflow(workflowId); + const staffId = await assign(live, nodeId); + const task = await deps.tasks.start({ + activityId: node.activityId, + ownerId: staffId as StaffId, + title: node.label, + workflowId, + nodeId, + data: payload, + ...(node.message === undefined ? {} : { message: node.message }), + }); + const instances = await deps.store.listInstances(workflowId); + const instance = nextInstanceNumber(instances, nodeId); + await deps.store.insertInstance(workflowId, { + nodeId, + instance, + running: true, + waiting: false, + activated: true, + taskId: task.id, + assigneeId: staffId, + }); + await deps.events.publish({ workflowId, kind: 'task-started', detail: { nodeId, taskId: task.id } }); + return { taskId: task.id, instance }; + }, + + raiseApproval: async ({ workflowId, nodeId, node, payload }) => { + const live = await requireWorkflow(workflowId); + const staffId = await assign(live, nodeId); + const approval = await deps.approvals.raise({ + workflowId, + nodeId, + approverId: staffId as StaffId, + subject: node.label, + basis: Object.entries(payload).map(([k, v]) => ({ k, v: String(v) })), + }); + const instances = await deps.store.listInstances(workflowId); + const instance = nextInstanceNumber(instances, nodeId); + await deps.store.insertInstance(workflowId, { + nodeId, + instance, + running: false, + waiting: true, + activated: true, + approvalId: approval.id, + assigneeId: staffId, + }); + await deps.events.publish({ workflowId, kind: 'approval-raised', detail: { nodeId, approvalId: approval.id } }); + return { approvalId: approval.id, instance }; + }, + + assembleEdge: async ({ workflowId, edge, payload }) => { + const live = await requireWorkflow(workflowId); + const scope = scopeOf(live); + return assemble(edge, { ...scope, ports: { ...scope.ports, [edge.from]: payload } }, deps.sfeel.expression); + }, + + evaluate: async ({ workflowId, condition, payload }) => { + const live = await requireWorkflow(workflowId); + const scope = scopeOf(live); + return deps.sfeel.condition(condition, { ...scope.initiation, ...scope.vars, ...payload }); + }, + + finish: async ({ workflowId, nodeId, payload }) => { + const live = await requireWorkflow(workflowId); + const instances = await deps.store.listInstances(workflowId); + await deps.store.insertInstance(workflowId, { + nodeId, + instance: nextInstanceNumber(instances, nodeId), + running: false, + waiting: false, + activated: true, + terminal: 'released', + }); + const finished = await deps.store.updateWorkflow(workflowId, { + state: 'completed', + finishedAt: deps.clock.now().toISOString(), + ports: { ...live.ports, __output: payload }, + }); + await deps.events.publish({ workflowId, kind: 'workflow-completed', detail: payload }); + if (live.source.by !== undefined) { + await deps.receipts.notify({ + to: live.source.by as StaffId, + workflowId, + subject: live.subject, + state: finished.state, + }); + } + }, + + deadEnd: async ({ workflowId, nodeId, payload }) => { + const outgoing = pkg.edges.filter((e) => e.from === nodeId); + await deps.events.publish({ + workflowId, + kind: 'route-dead-end', + detail: { + nodeId, + edges: outgoing.map((e) => ({ id: e.id, to: e.to, condition: e.condition ?? '(无条件)' })), + availableKeys: Object.keys(payload).sort(), + }, + }); + }, + }); + + compiled.set(key, flows); + return flows; + } + + const flowFor = (flows: readonly CompiledFlow[], pkgId: string, entryNodeId: string): CompiledFlow => { + const hit = flows.find((f) => f.key === `${pkgId}@${entryNodeId}`); + if (hit === undefined) throw new Error(`没有以 ${entryNodeId} 为入口的编译产物 —— 回边目标须编成变体`); + return hit; + }; + + /** run 号:一单一序 —— `#`,重开递增,旧 run 冻结可回查 */ + const runIdOf = (workflowId: string, seq: number) => `${workflowId}#${seq}`; + + /** + * run 结束后的收口:走到终点就完了;要重开就**起新 run**(路线 B 的核心); + * 死路已在编译产物里发过诊断事件,这里只让它停住。 + */ + async function settle(record: WorkflowRecord, pkg: ProcessPackage, result: RunResult | undefined, seq: number): Promise { + if (result === undefined || result.kind !== 'reopen') return; + const flows = flowsFor(pkg); + const target = result.reopenTarget; + if (target === undefined) return; + await deps.events.publish({ + workflowId: record.id, + kind: 'reopened', + detail: { by: result.reopenedBy, target, from: result.nodeId }, + }); + const next = seq + 1; + const out = await deps.runner.startRun({ + workflow: flowFor(flows, pkg.id, target).workflow, + runId: runIdOf(record.id, next), + resourceId: record.id, + inputData: { workflowId: record.id, nodeId: target, payload: result.payload }, + }); + await settle(record, pkg, out.result, next); + } + + /** 这一单现在跑到第几个 run —— 实例表里没有,记在工作流的 vars 上(胶合层自己的账) */ + const seqOf = (record: WorkflowRecord): number => Number(record.vars['__runSeq'] ?? 1); + + async function bumpSeq(workflowId: string, seq: number): Promise { + const live = await requireWorkflow(workflowId); + await deps.store.updateWorkflow(workflowId, { vars: { ...live.vars, __runSeq: seq } }); + } + + async function refreshState(workflowId: string): Promise { + const record = await requireWorkflow(workflowId); + if (record.state === 'terminated') return 'terminated'; + const instances = await deps.store.listInstances(workflowId); + const pkg = await deps.store.getPackage(record.packageId, record.packageVersion); + const endNodeIds = (pkg?.nodes ?? []).filter((n) => n.kind === 'end').map((n) => n.id); + const state = aggregateWorkflowState(instances as NodeInstance[], false, endNodeIds); + if (state !== record.state) { + await deps.store.updateWorkflow(workflowId, { + state, + ...(state === 'completed' ? { finishedAt: deps.clock.now().toISOString() } : {}), + }); + } + return state; + } + + /** + * 把终态送进 run。**先在实例表定位、确认是当前 open 的那个**, + * 不是就丢弃 —— 迟到/重复回调直接甩给 Mastra 会打坏 run(spike 实测)。 + */ + async function deliver( + workflowId: string, + nodeId: string, + resumeData: Record, + ): Promise { + const record = await requireWorkflow(workflowId); + const pkg = await requirePackage(record.packageId, record.packageVersion); + const flows = flowsFor(pkg); + const seq = seqOf(record); + // 当前 run 的入口:第一个 run 从 start,重开后的 run 从回边目标 —— 记在 vars 上 + const entry = String(record.vars['__runEntry'] ?? pkg.nodes.find((n) => n.kind === 'start')?.id ?? 'start'); + const out = await deps.runner.resumeRun({ + workflow: flowFor(flows, pkg.id, entry).workflow, + runId: runIdOf(workflowId, seq), + step: nodeId, + resumeData, + }); + await settle(record, pkg, out.result, seq); + } + + return { + async begin(record, pkg) { + const flows = flowsFor(pkg); + const start = pkg.nodes.find((n) => n.kind === 'start'); + if (start === undefined) return; + await deps.store.updateWorkflow(record.id, { vars: { ...record.vars, __runSeq: 1, __runEntry: start.id } }); + const out = await deps.runner.startRun({ + workflow: flowFor(flows, pkg.id, start.id).workflow, + runId: runIdOf(record.id, 1), + resourceId: record.id, + inputData: { workflowId: record.id, nodeId: start.id, payload: record.initiation }, + }); + await settle(record, pkg, out.result, 1); + }, + + async onTaskTerminal(taskId, terminal, ports) { + const located = await deps.store.findInstanceByTask(taskId); + if (located === undefined) return undefined; + + const record = await requireWorkflow(located.workflowId); + const pkg = await requirePackage(record.packageId, record.packageVersion); + const node = pkg.nodes.find((n) => n.id === located.nodeId); + + // 迟到回调:这个实例已经终态了 —— 丢弃,绝不甩给 Mastra(会打坏 run) + const instances = await deps.store.listInstances(record.id); + const current = instances.filter((i) => i.nodeId === located.nodeId).at(-1); + if (current?.instance !== located.instance || current.terminal !== undefined) { + await deps.events.publish({ + workflowId: record.id, + kind: 'stale-callback', + detail: { taskId, nodeId: located.nodeId, instance: located.instance, terminal }, + }); + return refreshState(record.id); + } + + await deps.store.updateInstance(record.id, located.nodeId, located.instance, { + running: false, + waiting: false, + terminal, + }); + await deps.events.publish({ workflowId: record.id, kind: 'task-terminal', detail: { taskId, terminal } }); + await deps.store.updateWorkflow(record.id, { ports: { ...record.ports, ...ports } }); + + if (terminal === 'released') { + await deliver(record.id, located.nodeId, { terminal, ports: Object.assign({}, ...Object.values(ports)) }); + } else if (node?.kind === 'activity') { + await this.applyFailure(record.id, located.nodeId, located.instance, node, terminal); + } + return refreshState(record.id); + }, + + /** + * 失败走向四选一 —— **不进 Mastra**:改道/升审批/容忍都是「另起一段」, + * 交给自持路径处理更直接(也避开 `.parallel()` 那套「任一失败即整体失败」的语义)。 + * 这一段与自持实现同源:走向声明式,流程层不自动重试。 + */ + async applyFailure(workflowId, nodeId, instance, node, terminal) { + const record = await requireWorkflow(workflowId); + const pkg = await requirePackage(record.packageId, record.packageVersion); + const disposition = node.failure; + await deps.store.updateInstance(workflowId, nodeId, instance, { failureDigested: isDigested(disposition.kind) }); + await deps.events.publish({ + workflowId, + kind: 'failure-disposition', + detail: { nodeId, terminal, disposition: disposition.kind }, + }); + + if (disposition.kind === 'reroute' || disposition.kind === 'escalate' || disposition.kind === 'tolerate') { + /* + * 三种「继续走」的走向都等价于「从某个节点重新入场」—— 用重开机制表达: + * 起一个以目标节点为入口的新 run(与回边同一条路)。 + * 目标编不出变体(不是回边目标)时如实报事件,不静默停住。 + */ + const targetId = + disposition.kind === 'reroute' + ? pkg.edges.find((e) => e.id === disposition.edgeId)?.to + : disposition.kind === 'escalate' + ? disposition.approvalNodeId + : undefined; + if (targetId !== undefined) { + const flows = flowsFor(pkg); + const variant = flows.find((f) => f.key === `${pkg.id}@${targetId}`); + if (variant === undefined) { + await deps.events.publish({ + workflowId, + kind: 'node-start-failed', + detail: { nodeId: targetId, from: nodeId, error: '失败走向的目标没有编译变体(须为回边目标或入口)' }, + }); + } else { + const seq = seqOf(record) + 1; + await bumpSeq(workflowId, seq); + await deps.store.updateWorkflow(workflowId, { + vars: { ...record.vars, __runSeq: seq, __runEntry: targetId }, + }); + const out = await deps.runner.startRun({ + workflow: variant.workflow, + runId: runIdOf(workflowId, seq), + resourceId: workflowId, + inputData: { workflowId, nodeId: targetId, payload: { __terminal: terminal, __from: nodeId } }, + }); + await settle(record, pkg, out.result, seq); + } + } + } + return refreshState(workflowId); + }, + + async onApprovalVerdict(workflowId, nodeId, verdict, payload) { + const record = await requireWorkflow(workflowId); + const pkg = await requirePackage(record.packageId, record.packageVersion); + const node = pkg.nodes.find((n) => n.id === nodeId); + if (node?.kind !== 'approval') throw new Error(`节点不是审批点:${nodeId}`); + + const instances = await deps.store.listInstances(workflowId); + const current = instances.filter((i) => i.nodeId === nodeId).at(-1); + if (current !== undefined) { + await deps.store.updateInstance(workflowId, nodeId, current.instance, { waiting: false, terminal: 'released' }); + } + await deps.events.publish({ workflowId, kind: 'approval-verdict', detail: { nodeId, verdict } }); + + // 裁决进判据作用域(系统注入的隐性上下文,不走映射) + await deliver(workflowId, nodeId, { verdict, ports: payload }); + + /* + * 图上没画驳回边的包:退回 `reject` 声明。 + * 编译产物里那条路会走到「全不中 → 死路」,诊断已发;这里补上声明的去向。 + */ + if (verdict === 'reject') { + const hasRejectEdge = pkg.edges.some((e) => e.from === nodeId && (e.condition ?? '').includes('reject')); + if (!hasRejectEdge) { + const route = rejectRouteOf(node, scopeOf(record), deps.sfeel.expression); + if (route.kind === 'terminate') { + await deps.store.updateWorkflow(workflowId, { + state: 'terminated', + finishedAt: deps.clock.now().toISOString(), + }); + } + } + } + return refreshState(workflowId); + }, + + refreshState, + + async terminate(workflowId, reason) { + await deps.store.updateWorkflow(workflowId, { + state: 'terminated', + finishedAt: deps.clock.now().toISOString(), + }); + await deps.events.publish({ workflowId, kind: 'workflow-terminated', detail: { reason } }); + return 'terminated'; + }, + }; +} diff --git a/docs/debt.md b/docs/debt.md index 8e4380a..72a0683 100644 --- a/docs/debt.md +++ b/docs/debt.md @@ -35,8 +35,8 @@ ADR-011 的「绑定先于策略」随岗位一起退场 —— 工作组快照 |---|---|---| | 0 | loop 三修(提交放行发验收卡 / 事件只记不跑 / 轨迹喂回叙事面) | ✅ 已落 | | 1 | 抽 `FlowRuntime` 端口:执行面成可替换切面,usecase 成薄壳 | ✅ 已落(八例 E2E 为等价性护栏) | -| 2 | reopen 回边 spike:`.dowhile` 子图 vs 外部胶合新 run | 进行中(`spike/mastra-reopen/`) | -| 3 | Mastra 编译器 = `FlowRuntime` 第二实现 | 待 spike 结论 | +| 2 | reopen 回边 spike:`.dowhile` 子图 vs 外部胶合新 run | ✅ 已定案:**路线 B**(`spike/mastra-reopen/FINDINGS.md`) | +| 3 | Mastra 编译器 = `FlowRuntime` 第二实现 | ✅ 编译器 + 胶合层已落(路线 B);**尚欠 `MastraRunner` 的真实现与接线开关** —— 见下 | | 4 | Agent 开环:Mastra `Agent` + 工具总线 | ✅ 已落(`JCAI_AGENT_OPEN_LOOP=1` 开启,缺省仍单拍) | **解耦本身已达成**(不依赖阶段 3):编排层对活动只有两个触点(`TaskGateway.start` @@ -44,10 +44,15 @@ ADR-011 的「绑定先于策略」随岗位一起退场 —— 工作组快照 `new-package-flow.it.test.ts`:从零建一条新流(建包 → 链接已装载的活动 → 双判据激活 → 起单 → 走完),全程没碰活动一行代码。 -阶段 3 的已知语义冲突(编译器必须绕,详见 process/CONTRACT.md): -回边 reopen 无原语、`.parallel()` 任一失败即整体失败(与「局部失败不连坐」相反)、 -三态聚合投影(`abnormal` 防假成功,Mastra run 无此态)、`node_instance` -投影表必须照写(治理台按行查)。 +**阶段 3 的剩余欠账**:`MastraRunner`(startRun / resumeRun 的真实现,需要 +`Mastra` 实例 + `PostgresStore` 挂 `mastra` schema)与接线开关未做 —— +编译器与胶合层都已落且有单测,但**没有在真库上跑过一整条流**; +转正之前自持实现仍是唯一在跑的路径。真跑时要验的两条(spike 已点名): +跨进程 `createRun({ runId })` 取句柄再 resume、Postgres 下的快照并发写。 + +已绕过的语义冲突(详见 process/CONTRACT.md):回边 reopen(路线 B 解决)、 +`.parallel()` 任一失败即整体失败(失败走向不进 Mastra)、三态聚合投影 +(读实例表不读 run 状态)、`node_instance` 投影表照写(发号权在主工程)。 ## 鉴权(ADR-010 落地后的已知欠账)