M4: 范式引擎与任务运行时

skill-engine(L4):载方法不载权力(白名单外工具即拒)、结晶片段经 ToolInvoker 留痕、
  装配入信封、技能目录渐进披露

flow-engine(L5):四种步型、确定性谓词跳步、重试与声明给定的兜底、
  异步步即监听(时限必配)、写类工具登记动作意向、步序位置持久

loop-engine(L5):自研 CMMN 子集解析器(表外构件直接报错,子集封顶可机器证明)、
  两副标准生命周期与迁移拓扑(引擎不得私改)、Enabled ≠ Active、
  语义性前提标待裁量评估(引擎不代裁量)、DMN 确定性求值 + 命中行留痕、
  DRD 缺口演绎与拓扑序、双向驱动与交集导航(断点即人环)、三型行动、
  终局唯放行与 Harness

task(L6):终态五名裁定(released 唯一成功)、Harness 预算环外强制与停滞熔断
  (监听中是健康态)、控制三语义(叫停不是终态,记最重档纠正)、改派留痕通知双方、
  断点续跑三份持久状态、封卷触发蒸馏钩子

301 个测试通过,门禁五道全绿

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
JCAI Main Agent
2026-07-27 01:51:22 +08:00
co-authored by Claude Opus 5
parent 2850e6d4ef
commit 3c974d2860
55 changed files with 7113 additions and 1 deletions
@@ -0,0 +1,31 @@
# module: flow-engine
职责: flow 范式:步序声明的装载与静态检查、四种步型的执行、条件跳步、
重试与声明给定的兜底、异步步的监听登记与事件续跑、步序位置的持久
不负责: 任务生命周期与终态裁定(见 task)、放行与落地(见 release)、
复杂路由(**不得在 flow 内表达** —— 上升为流程层网关或改用 loop)
公开出口: index.ts
- 类型:`FlowEngineService` `FlowPosition` `StepRuntime` `AdvanceOutcome` `FailureAction` `NextAction`
- 错误:`FlowDeclarationInvalidError`
- 端口类型:`FlowStateStore` `PredicateEvaluator` `TaskDataGateway` `ToolStepInvoker`
`LlmStepGateway` `CardGateway` `ListenerRegistrar` `MemoryRecorder` `TaskContext`
- 用例工厂:`createFlowRunner(deps): FlowEngineService`
- 纯函数:`nextAction` `markStep` `failureAction` `checkDeclaration` `initialPosition`
`lastStepIsAcceptance` `parallelGroupComplete`
- adapter:`createFlowStateStore`;Drizzle 表:`flowStateTable`
层级: L5(编码规范 §4)
依赖: @contracts;@shared/db;skill-engine(L4,步引用结晶片段);
port 注入:ledger / toolbox / llm / human-tool / event / memory
被依赖: task
不变量(AM 05 / 12.3):
- **零临场调度**:下一步由 `nextAction` 从声明算出,LLM 只以「被调用的工具」出现
- **条件必谓词**:跳步条件经 `PredicateEvaluator`(@sfeel)确定性求值,零 LLM
- **失败路径声明给定**:`failureAction` 只从声明读重试次数与兜底,不临场发明
- **异步步 = 监听**:人类工具步登记人事件监听,**时限必配**,无死等
- **出参落草稿层**:步的产出是草稿态,放行前不落事实
- **写类工具登记为动作意向**:flow 里同样不即时触达世界(除契约显式预授权)
- **谱全程可静态检查**:`checkDeclaration` 不过即 `FlowDeclarationInvalidError`,不得上架
- 步序位置持久于 `flowStateTable` —— 断点续跑的三份持久状态之一
@@ -0,0 +1,38 @@
/**
* module: flow-engine —— 对外类型(编码规范 §10.1;语义出处 AM 05)。
*
* flow 范式:步序声明运行器(四种步型、重试与兜底、事件即步条件)。
* **零临场调度** —— 运行中不得由 LLM 决定步序、跳步或兜底。
*/
import type { FlowModel, FlowStep, TaskId } from '@contracts';
import type { FailureAction, FlowIssue, FlowPosition, NextAction, StepRuntime, StepState } from './internal/domain/step-sequence.ts';
export type { FailureAction, FlowIssue, FlowModel, FlowPosition, FlowStep, NextAction, StepRuntime, StepState };
export class FlowDeclarationInvalidError extends Error {
constructor(readonly issues: readonly FlowIssue[]) {
super(`步序声明不完整,不得上架:${issues.map((i) => `${i.step}:${i.message}`).join('; ')}`);
this.name = 'FlowDeclarationInvalidError';
}
}
/** 一次推进的结果 —— 由 task 驱动,不自转 */
export type AdvanceOutcome = {
readonly position: FlowPosition;
readonly status: 'running' | 'awaiting' | 'finished' | 'blocked';
/** awaiting 时:在等什么 */
readonly waitingFor?: { readonly kind: 'human' | 'event' | 'time'; readonly ref: string; readonly deadline: string };
/** finished 时:末步验收工单的裁决决定终态 */
readonly released?: boolean;
readonly blockedReason?: string;
};
export type FlowEngineService = {
/** 装载步序声明并校验 —— 谱必须全程可静态检查 */
load(taskId: TaskId, model: FlowModel): Promise<FlowPosition>;
position(taskId: TaskId): Promise<FlowPosition | undefined>;
/** 推进一步(或若干确定步);异步步进入 awaiting 并登记监听 */
advance(taskId: TaskId): Promise<AdvanceOutcome>;
/** 事件即步的完成条件:人事件 / 数据事件 / 时间事件到达即续跑 */
resume(taskId: TaskId, event: { kind: 'human' | 'data' | 'time'; ref: string; payload: Record<string, unknown> }): Promise<AdvanceOutcome>;
};
+47
View File
@@ -0,0 +1,47 @@
/**
* module: flow-engine —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。
*/
export type {
AdvanceOutcome,
FailureAction,
FlowEngineService,
FlowIssue,
FlowModel,
FlowPosition,
FlowStep,
NextAction,
StepRuntime,
StepState,
} from './contract.ts';
export { FlowDeclarationInvalidError } from './contract.ts';
export type {
CardGateway,
Clock,
FlowStateStore,
IdGenerator,
ListenerRegistrar,
LlmStepGateway,
MemoryRecorder,
PredicateEvaluator,
TaskContext,
TaskDataGateway,
ToolStepInvoker,
} from './port.ts';
export type { FlowRunnerDeps } from './internal/usecase/flow-runner.ts';
export { createFlowRunner } from './internal/usecase/flow-runner.ts';
export {
checkDeclaration,
failureAction,
initialPosition,
lastStepIsAcceptance,
markStep,
nextAction,
parallelGroupComplete,
STEP_STATES,
} from './internal/domain/step-sequence.ts';
export { createFlowStateStore } from './internal/adapter/flow-state-store.ts';
export { flowStateTable } from './internal/adapter/flow-state-schema.ts';
@@ -0,0 +1,14 @@
/**
* flow-engine 的 sys schema 表(编码规范 §7 / ADR-002)。
* 运行时实例状态 —— 断点续跑的三份持久状态之一(OS 09)。
*/
import { jsonb, text, timestamp } from 'drizzle-orm/pg-core';
import { sys } from '@shared/db.ts';
export const flowStateTable = sys.table('flow_state', {
taskId: text('task_id').primaryKey(),
model: jsonb('model').notNull(),
/** 步序位置 */
position: jsonb('position').notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
});
@@ -0,0 +1,30 @@
/**
* flow-engine 存储 adapter —— Drizzle 只准出现在 adapter(编码规范 §5.3)。
*/
import { eq } from 'drizzle-orm';
import type { FlowModel, TaskId } from '@contracts';
import { flowModelSchema } from '@contracts';
import type { Db } from '@shared/db.ts';
import type { FlowStateStore } from '../../port.ts';
import type { FlowPosition } from '../domain/step-sequence.ts';
import { flowStateTable } from './flow-state-schema.ts';
export function createFlowStateStore(db: Db): FlowStateStore {
return {
async save(taskId: TaskId, model: FlowModel, position: FlowPosition) {
await db
.insert(flowStateTable)
.values({ taskId, model, position, updatedAt: new Date() })
.onConflictDoUpdate({
target: flowStateTable.taskId,
set: { model, position, updatedAt: new Date() },
});
},
async load(taskId: TaskId) {
const rows = await db.select().from(flowStateTable).where(eq(flowStateTable.taskId, taskId)).limit(1);
const row = rows[0];
if (row === undefined) return undefined;
return { model: flowModelSchema.parse(row.model), position: row.position as FlowPosition };
},
};
}
@@ -0,0 +1,123 @@
/**
* flow 步序推进 —— 纯逻辑,零 IO(编码规范 §5.3;语义出处 AM 05)。
*
* 「flow 是活动内的**确定性范式**:业务模型 = 步序声明 —— 一条谱;
* 引擎照谱执行,**零临场调度**,调度权归系统。
* 差异只有一处:下一步由**声明给定**,不由 AI 决断。
* LLM 在 flow 里可以出现,但只以『被调用的工具』出现,不是调度者。」
*/
import type { FlowStep } from '@contracts';
export const STEP_STATES = ['pending', 'running', 'awaiting', 'done', 'skipped', 'failed'] as const;
export type StepState = (typeof STEP_STATES)[number];
export type StepRuntime = {
readonly name: string;
readonly state: StepState;
readonly attempt: number;
readonly output?: Record<string, unknown>;
readonly error?: string;
};
export type FlowPosition = {
/** 步序位置 —— 三份持久状态之一(OS 09 断点续跑) */
readonly index: number;
readonly steps: readonly StepRuntime[];
};
export function initialPosition(steps: readonly FlowStep[]): FlowPosition {
return { index: 0, steps: steps.map((s) => ({ name: s.name, state: 'pending', attempt: 0 })) };
}
/** 条件跳步:条件必须是任务数据上的**确定性谓词**,由引擎求值,零 LLM(AM 5.2) */
export type PredicateEvaluator = (expression: string, scope: Readonly<Record<string, unknown>>) => boolean;
export type NextAction =
| { readonly kind: 'run'; readonly step: FlowStep; readonly index: number }
| { readonly kind: 'skip'; readonly step: FlowStep; readonly index: number; readonly reason: string }
| { readonly kind: 'await'; readonly step: FlowStep; readonly index: number }
| { readonly kind: 'finished' };
/**
* 下一步由声明给定。异步步(人类工具步 / 等事件的步)进入 `await` —— 完成条件 = 监听。
*/
export function nextAction(
declaration: readonly FlowStep[],
position: FlowPosition,
scope: Readonly<Record<string, unknown>>,
evaluate: PredicateEvaluator,
): NextAction {
for (let i = position.index; i < declaration.length; i += 1) {
const step = declaration[i]!;
const runtime = position.steps[i];
if (runtime?.state === 'done' || runtime?.state === 'skipped') continue;
if (runtime?.state === 'awaiting') return { kind: 'await', step, index: i };
if (step.when !== undefined && !evaluate(step.when, scope)) {
return { kind: 'skip', step, index: i, reason: `条件不成立:${step.when}` };
}
return { kind: 'run', step, index: i };
}
return { kind: 'finished' };
}
export function markStep(position: FlowPosition, index: number, patch: Partial<StepRuntime>): FlowPosition {
const steps = position.steps.map((s, i) => (i === index ? { ...s, ...patch } : s));
const advanced = patch.state === 'done' || patch.state === 'skipped';
return { index: advanced ? index + 1 : index, steps };
}
/* ── 失败与缺口:声明给定,不临场(AM 5.3) ────────────────────────── */
export type FailureAction =
| { readonly kind: 'retry'; readonly attempt: number; readonly backoff: string }
/** 转人兜底:改发人类工具卡,卡内容随声明 */
| { readonly kind: 'human'; readonly workOrder: 'execution' | 'acceptance' }
/** 受阻待人:挂起,携阻因 */
| { readonly kind: 'blocked' };
/**
* 「每步声明重试次数与间隔;重试仍败,按声明走下一步。
* **flow 内不得出现『由 AI 决定怎么办』的失败处理** —— 需要临场,即说明该活动不该是 flow。」
*/
export function failureAction(step: FlowStep, attempt: number): FailureAction {
if (attempt < step.failure.retry.times) {
return { kind: 'retry', attempt: attempt + 1, backoff: step.failure.retry.backoff };
}
return step.failure.fallback.kind === 'human'
? { kind: 'human', workOrder: step.failure.fallback.workOrder }
: { kind: 'blocked' };
}
/* ── 静态检查:谱必须全程可静态检查(AM 12.3) ────────────────────── */
export type FlowIssue = { readonly step: string; readonly message: string };
export function checkDeclaration(steps: readonly FlowStep[]): FlowIssue[] {
const out: FlowIssue[] = [];
const names = new Set<string>();
for (const step of steps) {
if (names.has(step.name)) out.push({ step: step.name, message: '步名重复' });
names.add(step.name);
// 步序声明完整:步型 / 入参映射 / 出参写入 / 完成条件 / 失败策略齐备
if (step.failure.retry.times < 0) out.push({ step: step.name, message: '重试次数不得为负' });
if (step.kind === 'human' && step.deadline.trim() === '') {
out.push({ step: step.name, message: '人类工具步必配时限 —— 无死等' });
}
}
return out;
}
/** 终态映射(AM 5.3):放行完成 = 末步验收工单「放行通过」 */
export function lastStepIsAcceptance(steps: readonly FlowStep[]): boolean {
const last = steps.at(-1);
return last !== undefined && last.kind === 'human' && last.workOrder === 'acceptance';
}
/** 并行组:组内步无依赖、**全完成再继续** */
export function parallelGroupComplete(position: FlowPosition, members: readonly string[]): boolean {
return members.every((name) => {
const runtime = position.steps.find((s) => s.name === name);
return runtime?.state === 'done' || runtime?.state === 'skipped';
});
}
@@ -0,0 +1,279 @@
/**
* flow 运行器 —— 照谱执行,零临场调度(编码规范 §5.3;语义出处 AM 05)。
*/
import type { FlowStep, TaskId } from '@contracts';
import type { AdvanceOutcome, FlowEngineService } from '../../contract.ts';
import { FlowDeclarationInvalidError } from '../../contract.ts';
import type {
CardGateway,
Clock,
FlowStateStore,
ListenerRegistrar,
LlmStepGateway,
MemoryRecorder,
PredicateEvaluator,
TaskContext,
TaskDataGateway,
ToolStepInvoker,
} from '../../port.ts';
import type { FlowPosition } from '../domain/step-sequence.ts';
import {
checkDeclaration,
failureAction,
initialPosition,
markStep,
nextAction,
} from '../domain/step-sequence.ts';
export type FlowRunnerDeps = {
readonly state: FlowStateStore;
readonly evaluate: PredicateEvaluator;
readonly data: TaskDataGateway;
readonly tools: ToolStepInvoker;
readonly llm: LlmStepGateway;
readonly cards: CardGateway;
readonly listeners: ListenerRegistrar;
readonly memory: MemoryRecorder;
readonly tasks: TaskContext;
readonly clock: Clock;
};
export function createFlowRunner(deps: FlowRunnerDeps): FlowEngineService {
async function loaded(taskId: TaskId) {
const found = await deps.state.load(taskId);
if (found === undefined) throw new Error(`flow 未装载:${taskId}`);
return found;
}
/** 出参写入落**草稿层**(草稿态);放行前不落事实 */
async function writeOutput(taskId: TaskId, step: FlowStep, output: Record<string, unknown>): Promise<void> {
if (step.output === undefined) return;
const patch: Record<string, unknown> = {};
for (const [field, path] of Object.entries(step.output.fields)) {
patch[field] = output[path] ?? output[field];
}
await deps.data.draftWrite({
taskId,
domain: step.output.domain,
object: step.output.object,
key: {},
patch,
});
}
function inputOf(step: FlowStep, scope: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [key, path] of Object.entries(step.input)) {
out[key] = path.split('.').reduce<unknown>((acc, seg) => {
if (acc === null || acc === undefined || typeof acc !== 'object') return undefined;
return (acc as Record<string, unknown>)[seg];
}, scope);
}
return out;
}
async function runOne(
taskId: TaskId,
step: FlowStep,
index: number,
position: FlowPosition,
): Promise<AdvanceOutcome> {
const context = await deps.tasks.of(taskId);
const scope = await deps.data.view(taskId);
const payload = inputOf(step, scope);
const attempt = (position.steps[index]?.attempt ?? 0) + 1;
await deps.memory.append({
taskId,
kind: 'action',
title: `步 ${index + 1} · ${step.label}(${step.kind})`,
detail: { step: step.name, attempt, input: payload },
});
switch (step.kind) {
case 'tool':
case 'pure-tool': {
// 写类工具在 flow 里同样登记为动作意向 —— 触达世界的动作集中在放行时落地
if (step.kind === 'tool' && (await deps.tools.isWriteEffect(step.tool))) {
await deps.tools.registerIntent({ taskId, tool: step.tool, input: payload, label: step.label });
const next = markStep(position, index, { state: 'done', attempt });
await deps.state.save(taskId, (await loaded(taskId)).model, next);
return { position: next, status: 'running' };
}
const result = await deps.tools.invoke({
tool: step.tool,
input: payload,
activityId: context.activityId,
taskId,
stepNo: index,
});
if (!result.ok) return handleFailure(taskId, step, index, position, attempt, result.error?.message ?? '工具失败');
if (result.output !== undefined) await writeOutput(taskId, step, result.output);
const next = markStep(position, index, { state: 'done', attempt, ...(result.output === undefined ? {} : { output: result.output }) });
await deps.state.save(taskId, (await loaded(taskId)).model, next);
return { position: next, status: 'running' };
}
case 'llm': {
// LLM 只以「被调用的工具」出现,不是调度者;模板与出参 schema 随声明版本冻结
const prompt = step.template.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_, path: string) => String(payload[path] ?? ''));
const result = await deps.llm.generate({
model: step.model,
prompt,
outputSchema: step.outputSchema,
taskId,
stepNo: index,
});
if (!result.ok) return handleFailure(taskId, step, index, position, attempt, result.error ?? 'LLM 步失败');
const output = (result.object ?? {}) as Record<string, unknown>;
await writeOutput(taskId, step, output);
const next = markStep(position, index, { state: 'done', attempt, output });
await deps.state.save(taskId, (await loaded(taskId)).model, next);
return { position: next, status: 'running' };
}
case 'human': {
// 异步步:完成条件 = 人事件;时限必配
const card = await deps.cards.issue({
taskId,
ownerId: context.ownerId,
humanCallCap: context.humanCallCap,
workOrder: step.workOrder,
card: step.card,
});
await deps.listeners.register({
taskId,
eventType: 'human',
keyFilter: { cardId: card.id },
deadline: step.deadline,
});
const next = markStep(position, index, { state: 'awaiting', attempt });
await deps.state.save(taskId, (await loaded(taskId)).model, next);
return {
position: next,
status: 'awaiting',
waitingFor: { kind: 'human', ref: card.id, deadline: step.deadline },
};
}
}
}
/** 失败路径**声明给定**,不临场发明 */
async function handleFailure(
taskId: TaskId,
step: FlowStep,
index: number,
position: FlowPosition,
attempt: number,
error: string,
): Promise<AdvanceOutcome> {
const action = failureAction(step, attempt);
await deps.memory.append({
taskId,
kind: 'result',
title: `步 ${index + 1} 失败:${error} → ${action.kind}`,
detail: { step: step.name, attempt, action },
});
if (action.kind === 'retry') {
const next = markStep(position, index, { state: 'pending', attempt, error });
await deps.state.save(taskId, (await loaded(taskId)).model, next);
return { position: next, status: 'running' };
}
if (action.kind === 'human') {
const context = await deps.tasks.of(taskId);
const card = await deps.cards.issue({
taskId,
ownerId: context.ownerId,
humanCallCap: context.humanCallCap,
workOrder: action.workOrder,
card: { title: `步「${step.label}」重试仍败,转人兜底`, reason: error },
});
await deps.listeners.register({
taskId,
eventType: 'human',
keyFilter: { cardId: card.id },
deadline: 'P3D',
});
const next = markStep(position, index, { state: 'awaiting', attempt, error });
await deps.state.save(taskId, (await loaded(taskId)).model, next);
return { position: next, status: 'awaiting', waitingFor: { kind: 'human', ref: card.id, deadline: 'P3D' } };
}
const next = markStep(position, index, { state: 'failed', attempt, error });
await deps.state.save(taskId, (await loaded(taskId)).model, next);
return { position: next, status: 'blocked', blockedReason: error };
}
return {
async load(taskId, model) {
const issues = checkDeclaration(model.steps);
if (issues.length > 0) throw new FlowDeclarationInvalidError(issues);
const position = initialPosition(model.steps);
await deps.state.save(taskId, model, position);
await deps.memory.append({
taskId,
kind: 'engine-transition',
title: `装载步序声明:${model.steps.length} 步`,
});
return position;
},
async position(taskId) {
return (await deps.state.load(taskId))?.position;
},
async advance(taskId) {
const { model, position } = await loaded(taskId);
const scope = await deps.data.view(taskId);
const action = nextAction(model.steps, position, scope, deps.evaluate);
switch (action.kind) {
case 'finished': {
// 末步是验收工单时,终态由其裁决给出;此处只报「谱走完」
return { position, status: 'finished', released: false };
}
case 'skip': {
await deps.memory.append({
taskId,
kind: 'engine-transition',
title: `跳步:${action.step.label} —— ${action.reason}`,
});
const next = markStep(position, action.index, { state: 'skipped' });
await deps.state.save(taskId, model, next);
return { position: next, status: 'running' };
}
case 'await':
return {
position,
status: 'awaiting',
waitingFor: { kind: 'event', ref: action.step.name, deadline: 'P3D' },
};
case 'run':
return runOne(taskId, action.step, action.index, position);
}
},
/** 事件即步的完成条件 —— 到达即续跑,挂起期间事件不丢 */
async resume(taskId, event) {
const { model, position } = await loaded(taskId);
const index = position.steps.findIndex((s) => s.state === 'awaiting');
if (index < 0) return { position, status: 'running' };
const step = model.steps[index];
await deps.memory.append({
taskId,
kind: 'engine-transition',
title: `事件到达,步 ${index + 1} 完成条件满足(${event.kind})`,
detail: { ref: event.ref },
});
if (step !== undefined && event.kind === 'human') {
await writeOutput(taskId, step, event.payload);
}
const next = markStep(position, index, { state: 'done' });
await deps.state.save(taskId, model, next);
return { position: next, status: 'running' };
},
};
}
@@ -0,0 +1,7 @@
{
"name": "@modules/flow-engine",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": { ".": "./index.ts" }
}
+94
View File
@@ -0,0 +1,94 @@
/**
* module: flow-engine —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3)。
*/
import type { FlowModel, StaffId, TaskId } from '@contracts';
import type { FlowPosition } from './internal/domain/step-sequence.ts';
export type Clock = { now(): Date };
export type IdGenerator = { next(): string };
/** 运行时实例状态 —— 三份持久状态之一(OS 09) */
export type FlowStateStore = {
save(taskId: TaskId, model: FlowModel, position: FlowPosition): Promise<void>;
load(taskId: TaskId): Promise<{ model: FlowModel; position: FlowPosition } | undefined>;
};
/** 条件谓词求值 —— app 注入 `@sfeel`;零 LLM */
export type PredicateEvaluator = (expression: string, scope: Readonly<Record<string, unknown>>) => boolean;
/** 任务数据 —— app 注入 `ledger`(L1):读任务视图 = 正式版 + 本任务草稿层 */
export type TaskDataGateway = {
view(taskId: TaskId): Promise<Record<string, unknown>>;
draftWrite(input: {
taskId: TaskId;
domain: string;
object: string;
key: Record<string, string | number>;
patch: Record<string, unknown>;
}): Promise<void>;
};
/** 工具步与纯工具步 —— app 注入 `toolbox`(L2) */
export type ToolStepInvoker = {
invoke(input: {
tool: string;
input: Record<string, unknown>;
activityId: string;
taskId: TaskId;
stepNo: number;
}): Promise<{ ok: boolean; output?: Record<string, unknown>; error?: { message: string; retryable: boolean } }>;
/** 写类工具在 flow 里也走「登记动作意向」,除非契约显式预授权 */
registerIntent(input: {
taskId: TaskId;
tool: string;
input: Record<string, unknown>;
label: string;
}): Promise<void>;
isWriteEffect(tool: string): Promise<boolean>;
};
/** LLM 步 —— app 注入 `llm`(L1)的 ModelGateway;模板与出参 schema 随声明版本冻结 */
export type LlmStepGateway = {
generate(input: {
model: string;
prompt: string;
outputSchema: Record<string, unknown>;
taskId: TaskId;
stepNo: number;
}): Promise<{ ok: boolean; object?: unknown; error?: string }>;
};
/** 人类工具步 —— app 注入 `human-tool`(L3) */
export type CardGateway = {
issue(input: {
taskId: TaskId;
ownerId: StaffId;
humanCallCap: number;
workOrder: 'execution' | 'acceptance';
card: Record<string, unknown>;
}): Promise<{ id: string }>;
};
/** 监听登记 —— app 注入 `event`(L1):异步步的完成条件 = 监听,时限必配 */
export type ListenerRegistrar = {
register(input: {
taskId: TaskId;
eventType: 'data' | 'time' | 'human';
keyFilter: Record<string, string>;
deadline: string;
}): Promise<{ id: string }>;
};
export type MemoryRecorder = {
append(input: {
taskId: TaskId;
kind: 'action' | 'result' | 'engine-transition';
title: string;
detail?: Record<string, unknown>;
}): Promise<void>;
};
/** 任务上下文 —— 活动 ID、负责人、预算 */
export type TaskContext = {
of(taskId: TaskId): Promise<{ activityId: string; ownerId: StaffId; humanCallCap: number }>;
};
@@ -0,0 +1,41 @@
# 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` `AgentPort` `ToolGateway`
`CardGateway` `ListenerRegistrar` `MemoryRecorder` `TaskContext`
- 用例工厂:`createTurnRunner(deps): LoopEngineService`
- 纯函数:`parseCmmn` `parseXml` `publishEnabled` `navigate` `evaluateCriterion`
`transitionItem` `occur` `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 对世界只有触达(动作,落在放行)与等待(监听)两种姿势
- **终局唯放行与 Harness**:引擎不把案计划置 Completed —— 那是 release 的事;
`mayCompleteCase` 只对 release 为真,`mayTerminateCase` 只对 harness / owner / governance 为真
- **驳回是迁移不是状态**:`reenter` 以 repetitionRule 新实例重新入场
@@ -0,0 +1,288 @@
/** CMMN 子集解析、生命周期与 DMN —— domain 测试零 mock(AM 03 / 04) */
import { describe, expect, it } from 'vitest';
import type { DmnDecisionTable, DmnModel } from '@contracts';
import { CmmnParseError, parseCmmn } from '../internal/domain/cmmn-parse.ts';
import {
autoCompleteReady,
canTransitionItem,
IllegalTransitionError,
initialRuntime,
mayCompleteCase,
mayTerminateCase,
navigate,
occur,
publishEnabled,
reenter,
transitionItem,
} from '../internal/domain/lifecycle.ts';
import { deduceGaps, evaluateTable, evaluationOrder } from '../internal/domain/dmn.ts';
/** 渠道寻源 A3 的极简案例模型 —— 取自 MVP 示例(S1 接需核史 → S2 选渠定策 → S3 发询比价) */
const XML = `<?xml version="1.0"?>
<definitions xmlns="http://www.omg.org/spec/CMMN/20151109/MODEL">
<case id="sourcing">
<caseFileModel>
<caseFileItem id="inquiry" name="询价请求"/>
<caseFileItem id="quotes" name="供应商回价单"/>
</caseFileModel>
<casePlanModel id="plan" name="渠道寻源" autoComplete="true">
<sentry id="s_after_inquiry">
<onPart id="op1" caseFileItemRef="inquiry"><standardEvent>create</standardEvent></onPart>
</sentry>
<sentry id="s_semantic">
<onPart id="op2" caseFileItemRef="quotes"><standardEvent>create</standardEvent></onPart>
<ifPart id="ip1"><condition>semantic: 回价是否可信</condition></ifPart>
</sentry>
<stage id="S1" name="接需核史" autoComplete="true">
<entryCriterion id="ec1" sentryRef="s_after_inquiry"/>
<humanTask id="T1" name="人工查历史报价">
<itemControl><manualActivationRule id="m1"/></itemControl>
</humanTask>
<decisionTask id="D1" name="判定采用策略" decisionRef="strategy"/>
<planningTable id="pt1">
<humanTask id="T2" name="人工渠道资质核实"/>
</planningTable>
</stage>
<milestone id="M1" name="策略已定"/>
<timerEventListener id="TL1" name="时限预警">
<timerExpression>P4D</timerExpression>
</timerEventListener>
</casePlanModel>
</case>
</definitions>`;
describe('parseCmmn —— 子集封顶', () => {
const model = parseCmmn(XML);
it('读出案计划与自动完成', () => {
expect(model.name).toBe('渠道寻源');
expect(model.autoComplete).toBe(true);
});
it('读出任务数据(CaseFileItem)', () => {
expect(model.fileItems.map((f) => f.id)).toEqual(['inquiry', 'quotes']);
});
it('读出全部计划项与类型', () => {
expect(model.items.map((i) => `${i.id}:${i.type}`)).toEqual([
'S1:stage',
'T1:humanTask',
'D1:decisionTask',
'T2:humanTask',
'M1:milestone',
'TL1:timerEventListener',
]);
});
it('manualActivationRule 被识别 —— Enabled ≠ Active', () => {
expect(model.items.find((i) => i.id === 'T1')?.manualActivation).toBe(true);
expect(model.items.find((i) => i.id === 'D1')?.manualActivation).toBe(false);
});
it('planning table 里的项标为裁量项', () => {
expect(model.items.find((i) => i.id === 'T2')?.discretionary).toBe(true);
expect(model.items.find((i) => i.id === 'T1')?.discretionary).toBe(false);
});
it('entryCriterion 关到 sentry 的 onPart', () => {
const stage = model.items.find((i) => i.id === 'S1');
expect(stage?.entryCriteria[0]?.onParts[0]).toEqual({ kind: 'file', ref: 'inquiry', event: 'create' });
});
it('decisionRef 与 timerExpression', () => {
expect(model.items.find((i) => i.id === 'D1')?.decisionRef).toBe('strategy');
expect(model.items.find((i) => i.id === 'TL1')?.timerExpression).toBe('P4D');
});
it('表外构件直接报错 —— 不静默忽略', () => {
const bad = XML.replace('<milestone id="M1" name="策略已定"/>', '<eventListener id="X"/>');
expect(() => parseCmmn(bad)).toThrow(CmmnParseError);
});
it('标签不匹配报错', () => {
expect(() => parseCmmn('<definitions><case id="a"></definitions>')).toThrow(CmmnParseError);
});
});
describe('生命周期 —— 语义忠实,引擎不得私改迁移拓扑', () => {
it('计划项八态的合法迁移', () => {
expect(canTransitionItem('Available', 'Enabled')).toBe(true);
expect(canTransitionItem('Enabled', 'Active')).toBe(true);
expect(canTransitionItem('Active', 'Completed')).toBe(true);
expect(canTransitionItem('Enabled', 'Completed')).toBe(false);
expect(canTransitionItem('Completed', 'Active')).toBe(false);
});
it('非法迁移抛错', () => {
const model = parseCmmn(XML);
const runtime = initialRuntime(model);
expect(() => transitionItem(runtime, 'T1', 'Completed')).toThrow(IllegalTransitionError);
});
it('里程碑只有一条主迁移 Available →(occur)→ Completed', () => {
const model = parseCmmn(XML);
const next = occur(initialRuntime(model), 'M1');
expect(next.items.find((i) => i.id === 'M1')?.state).toBe('Completed');
expect(next.milestones).toEqual(['M1']);
});
it('驳回是迁移不是状态:新实例重新入场', () => {
const model = parseCmmn(XML);
let runtime = transitionItem(initialRuntime(model), 'T1', 'Enabled');
runtime = transitionItem(runtime, 'T1', 'Active');
const next = reenter(runtime, 'T1');
expect(next.items.find((i) => i.id === 'T1')).toEqual({ id: 'T1', state: 'Available', instance: 2 });
});
});
describe('终局唯放行与 Harness —— AI 不得产生终局迁移', () => {
it('Completed 只出自放行', () => {
expect(mayCompleteCase('release')).toBe(true);
expect(mayCompleteCase('harness')).toBe(false);
expect(mayCompleteCase('owner')).toBe(false);
});
it('Terminated 只出自 Harness / 负责人 / 治理台', () => {
expect(mayTerminateCase('harness')).toBe(true);
expect(mayTerminateCase('owner')).toBe(true);
expect(mayTerminateCase('governance')).toBe(true);
expect(mayTerminateCase('release')).toBe(false);
});
});
describe('publishEnabled —— 引擎推进到 Enabled 为止', () => {
const model = parseCmmn(XML);
it('前提未满足时不发布', () => {
const enabled = publishEnabled(model, initialRuntime(model), {}, new Set(), () => true);
expect(enabled.entries.map((e) => e.id)).not.toContain('S1');
});
it('onPart 件到 + 无 ifPart → Enabled', () => {
const enabled = publishEnabled(model, initialRuntime(model), { inquiry: {} }, new Set(['inquiry']), () => true);
expect(enabled.entries.map((e) => e.id)).toContain('S1');
});
it('父阶段未激活的子项不入场', () => {
const enabled = publishEnabled(model, initialRuntime(model), { inquiry: {} }, new Set(['inquiry']), () => true);
expect(enabled.entries.map((e) => e.id)).not.toContain('T1');
});
it('manualActivation 的项标出「须 Agent 启动」', () => {
let runtime = initialRuntime(model);
runtime = transitionItem(runtime, 'S1', 'Enabled');
runtime = transitionItem(runtime, 'S1', 'Active');
const enabled = publishEnabled(model, runtime, { inquiry: {} }, new Set(['inquiry']), () => true);
expect(enabled.entries.find((e) => e.id === 'T1')?.requiresManualStart).toBe(true);
});
});
describe('navigate —— 导航即交集,断点即人环', () => {
it('有交集即择优推进', () => {
const nav = navigate(
{ entries: [{ id: 'a', name: 'A', type: 'humanTask', requiresManualStart: true, discretionary: false }], judgements: [], gaps: ['缺报价'], activeStages: [] },
new Set(['a']),
);
expect(nav.kind).toBe('proceed');
});
it('交集为空即断点 —— 调人或立监听的判定时机', () => {
const nav = navigate({ entries: [], judgements: [], gaps: ['缺客户信用'], activeStages: [] }, new Set(['a']));
expect(nav).toEqual({ kind: 'breakpoint', gaps: ['缺客户信用'] });
});
it('无缺口即收敛', () => {
expect(navigate({ entries: [], judgements: [], gaps: [], activeStages: [] }, new Set()).kind).toBe('converged');
});
});
describe('autoCompleteReady —— 齐了即收', () => {
it('必做项未完不收', () => {
const model = parseCmmn(XML);
expect(autoCompleteReady(model, initialRuntime(model))).toBe(false);
});
});
/* ── DMN:三分类规则 P-03 ─────────────────────────────────────────── */
const p03: DmnDecisionTable = {
id: 'p03',
label: '三分类规则 P-03',
hitPolicy: 'FIRST',
inputs: [
{ name: 'days', expression: 'historyDays' },
{ name: 'stable', expression: 'marketStable' },
],
outputs: [{ name: 'type' }, { name: 'play' }],
rules: [
{ id: 'r1', when: ['<= 90', 'true'], then: ['A', '直接快答'] },
{ id: 'r2', when: ['-', '-'], then: ['B', '重估后报'] },
],
};
const evalExpr = (expr: string, scope: Readonly<Record<string, unknown>>): unknown => scope[expr];
const evalTest = (cell: string, value: unknown): boolean => {
const trimmed = cell.trim();
if (trimmed === '-' || trimmed === '') return true;
if (trimmed.startsWith('<=')) return Number(value) <= Number(trimmed.slice(2));
if (trimmed === 'true') return value === true;
return String(value) === trimmed.replace(/^"|"$/g, '');
};
describe('evaluateTable —— 能算的不问 LLM', () => {
it('命中首行,留痕带命中规则行', () => {
const result = evaluateTable(p03, { historyDays: 30, marketStable: true }, evalExpr, evalTest);
expect(result.kind).toBe('decided');
if (result.kind === 'decided') {
expect(result.trace.hitRules).toEqual(['r1']);
expect(result.trace.outputs).toEqual({ type: 'A', play: '直接快答' });
expect(result.trace.inputs).toEqual({ days: 30, stable: true });
}
});
it('同输入必同结论 —— 零方差', () => {
const a = evaluateTable(p03, { historyDays: 30, marketStable: true }, evalExpr, evalTest);
const b = evaluateTable(p03, { historyDays: 30, marketStable: true }, evalExpr, evalTest);
expect(a).toEqual(b);
});
it('落到兜底行', () => {
const result = evaluateTable(p03, { historyDays: 200, marketStable: false }, evalExpr, evalTest);
if (result.kind === 'decided') expect(result.trace.outputs['type']).toBe('B');
});
it('输入不齐即 incomplete —— DRD 指出缺什么', () => {
const result = evaluateTable(p03, { historyDays: 30 }, evalExpr, evalTest);
expect(result).toEqual({ kind: 'incomplete', missing: ['stable'] });
});
});
describe('DRD —— 缺口演绎与拓扑序', () => {
const model: DmnModel = {
drd: [
{ decision: 'price', requires: ['margin'], inputs: ['basePrice'] },
{ decision: 'margin', requires: [], inputs: ['customerGrade', 'materialType'] },
],
tables: [],
};
it('反向遍历给出缺口', () => {
expect(deduceGaps(model, 'price', new Set(['basePrice']))).toEqual([
{ kind: 'decision', ref: 'margin' },
{ kind: 'input', ref: 'customerGrade' },
{ kind: 'input', ref: 'materialType' },
]);
});
it('已知项不再算缺口', () => {
expect(deduceGaps(model, 'price', new Set(['basePrice', 'margin']))).toEqual([]);
});
it('拓扑序:先算上游决策', () => {
expect(evaluationOrder(model, 'price')).toEqual(['margin', 'price']);
});
it('成环即报错', () => {
const cyclic: DmnModel = { drd: [{ decision: 'a', requires: ['b'], inputs: [] }, { decision: 'b', requires: ['a'], inputs: [] }], tables: [] };
expect(() => evaluationOrder(cyclic, 'a')).toThrow(/成环/);
});
});
@@ -0,0 +1,98 @@
/**
* module: loop-engine —— 对外类型(编码规范 §10.1;语义出处 AM 03 / 04)。
*
* loop 的运行 = **CMMN 语义引擎(下层,零 LLM)× Agent 回路(上层,临场裁量)**。
* 两层的耦合点是任务数据(驱动耦合)与任务记忆(记录同轴)。
*/
import type { LoopModel, TaskId } from '@contracts';
import type { CaseModel, PlanItem, PlanItemType } from './internal/domain/cmmn-parse.ts';
import type {
CaseRuntime,
CaseState,
EnabledEntry,
EnabledSet,
ItemRuntime,
ItemState,
Navigation,
TerminalAuthority,
} from './internal/domain/lifecycle.ts';
import type { DecisionResult, DecisionTrace, Gap } from './internal/domain/dmn.ts';
export type {
CaseModel,
CaseRuntime,
CaseState,
DecisionResult,
DecisionTrace,
EnabledEntry,
EnabledSet,
Gap,
ItemRuntime,
ItemState,
LoopModel,
Navigation,
PlanItem,
PlanItemType,
TerminalAuthority,
};
export { CmmnParseError } from './internal/domain/cmmn-parse.ts';
export { IllegalTransitionError } from './internal/domain/lifecycle.ts';
export class ForbiddenTerminalError extends Error {
constructor(readonly by: TerminalAuthority) {
super(`终局迁移不得由 ${by} 产生:Completed 必经放行,Terminated 只出自 Harness 与负责人(AM 4.1)`);
this.name = 'ForbiddenTerminalError';
}
}
export class AutomaticOnWorldTouchingError extends Error {
constructor(readonly itemId: string) {
super(`计划项 ${itemId} 触达世界却未声明 manualActivationRule —— Enabled ≠ Active(AM 12.2)`);
this.name = 'AutomaticOnWorldTouchingError';
}
}
/** Agent 的三型行动 —— 每一步行动属于且仅属于其一(AM 4.2) */
export type AgentAction =
/** 调工具:系统调用与 LLM 工具 */
| { readonly kind: 'tool'; readonly itemId: string; readonly tool: string; readonly input: Record<string, unknown> }
/** 调人:四卡之一 —— 调人 = 立监听听人 */
| { readonly kind: 'human'; readonly itemId: string; readonly card: Record<string, unknown>; readonly workOrder: 'execution' | 'acceptance' }
/** 立监听:AI 不能调用世界,对世界只有两种合法姿势 —— 触达它(动作)与等它(监听) */
| { readonly kind: 'listen'; readonly itemId: string; readonly eventType: 'data' | 'time'; readonly keyFilter: Record<string, string>; readonly deadline: string }
/** 启动一个 Enabled 项(manualStart,留痕) */
| { readonly kind: 'start'; readonly itemId: string }
/** 规划裁量项入场 */
| { readonly kind: 'plan'; readonly itemId: string }
/** 弃用某项(此单不走这条) */
| { readonly kind: 'disable'; readonly itemId: string; readonly reason: string }
/** 评估语义性前提(评估留痕,可回放可归因) */
| { readonly kind: 'judge'; readonly criterion: string; readonly satisfied: boolean; readonly rationale: string }
/** 判定成果已齐,提交放行 */
| { readonly kind: 'submit'; readonly rationale: string };
export type TurnOutcome = {
readonly runtime: CaseRuntime;
readonly enabled: EnabledSet;
readonly navigation: Navigation;
readonly action?: AgentAction;
readonly status: 'running' | 'awaiting' | 'submitted' | 'blocked';
readonly blockedReason?: string;
};
export type LoopEngineService = {
/** 装载:XML 进语义引擎,说明书进 Agent 上下文;版本全部入信封 */
load(taskId: TaskId, model: LoopModel): Promise<CaseRuntime>;
runtime(taskId: TaskId): Promise<CaseRuntime | undefined>;
/** 发布 Enabled 集与红绿灯 —— 引擎推进的终点 */
publish(taskId: TaskId): Promise<EnabledSet>;
/** 跑一个回合:定位 → 择向 → 决断 → 行动 → 写数据 */
runTurn(taskId: TaskId): Promise<TurnOutcome>;
/** 事件驱动续跑:数据 / 时间 / 人 / 控制 */
onEvent(taskId: TaskId, event: { kind: 'data' | 'time' | 'human' | 'control'; ref: string; payload: Record<string, unknown> }): Promise<TurnOutcome>;
/** DMN 确定性求值 —— 能算的不问 LLM */
decide(taskId: TaskId, decision: string): Promise<DecisionResult>;
/** 驳回:相关计划项以新实例重新入场(迁移不是状态) */
reenter(taskId: TaskId, itemId: string): Promise<CaseRuntime>;
};
+68
View File
@@ -0,0 +1,68 @@
/**
* module: loop-engine —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。
*/
export type {
AgentAction,
CaseModel,
CaseRuntime,
CaseState,
DecisionResult,
DecisionTrace,
EnabledEntry,
EnabledSet,
Gap,
ItemRuntime,
ItemState,
LoopEngineService,
LoopModel,
Navigation,
PlanItem,
PlanItemType,
TerminalAuthority,
TurnOutcome,
} from './contract.ts';
export {
AutomaticOnWorldTouchingError,
CmmnParseError,
ForbiddenTerminalError,
IllegalTransitionError,
} from './contract.ts';
export type {
AgentPort,
CardGateway,
Clock,
ListenerRegistrar,
LoopStateStore,
MemoryRecorder,
Sfeel,
TaskContext,
TaskDataGateway,
ToolGateway,
} from './port.ts';
export type { TurnRunnerDeps } from './internal/usecase/turn-runner.ts';
export { createTurnRunner } from './internal/usecase/turn-runner.ts';
export { parseCmmn, parseXml, PLAN_ITEM_TYPES } from './internal/domain/cmmn-parse.ts';
export {
assertItemTransition,
autoCompleteReady,
canTransitionCase,
canTransitionItem,
CASE_STATES,
evaluateCriterion,
initialRuntime,
ITEM_STATES,
mayCompleteCase,
mayTerminateCase,
navigate,
occur,
publishEnabled,
reenter,
transitionItem,
} from './internal/domain/lifecycle.ts';
export { deduceGaps, evaluateTable, evaluationOrder, isBindingConclusion } from './internal/domain/dmn.ts';
export { createLoopStateStore } from './internal/adapter/loop-state-store.ts';
export { loopStateTable } from './internal/adapter/loop-state-schema.ts';
@@ -0,0 +1,15 @@
/**
* loop-engine 的 sys schema 表(编码规范 §7 / ADR-002)。
* 运行时实例状态:loop 的**模型定位与就绪集** —— 断点续跑的三份持久状态之一(OS 09)。
*/
import { jsonb, text, timestamp } from 'drizzle-orm/pg-core';
import { sys } from '@shared/db.ts';
export const loopStateTable = sys.table('loop_state', {
taskId: text('task_id').primaryKey(),
model: jsonb('model').notNull(),
/** 解析后的 CMMN 子集结构 —— 免去每回合重解析 */
parsed: jsonb('parsed').notNull(),
runtime: jsonb('runtime').notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
});
@@ -0,0 +1,36 @@
/**
* loop-engine 存储 adapter —— Drizzle 只准出现在 adapter(编码规范 §5.3)。
*/
import { eq } from 'drizzle-orm';
import type { LoopModel, TaskId } from '@contracts';
import { loopModelSchema } from '@contracts';
import type { Db } from '@shared/db.ts';
import type { LoopStateStore } from '../../port.ts';
import type { CaseModel } from '../domain/cmmn-parse.ts';
import type { CaseRuntime } from '../domain/lifecycle.ts';
import { loopStateTable } from './loop-state-schema.ts';
export function createLoopStateStore(db: Db): LoopStateStore {
return {
async save(taskId: TaskId, model: LoopModel, parsed: CaseModel, runtime: CaseRuntime) {
const values = { taskId, model, parsed, runtime, updatedAt: new Date() };
await db
.insert(loopStateTable)
.values(values)
.onConflictDoUpdate({
target: loopStateTable.taskId,
set: { model, parsed, runtime, updatedAt: values.updatedAt },
});
},
async load(taskId: TaskId) {
const rows = await db.select().from(loopStateTable).where(eq(loopStateTable.taskId, taskId)).limit(1);
const row = rows[0];
if (row === undefined) return undefined;
return {
model: loopModelSchema.parse(row.model),
parsed: row.parsed as CaseModel,
runtime: row.runtime as CaseRuntime,
};
},
};
}
@@ -0,0 +1,348 @@
/**
* CMMN 子集解析 —— 纯函数,零 IO、零依赖(编码规范 §5.3 / §4)。
*
* **CMMN 子集封顶**:构件与生命周期以 AM 03 构件表 + AM 4.1 两副状态表为全集,
* 不实现 CMMN 1.1 其余构件 —— 扩构件 = 改 §4 那一行 + ADR。
*
* 子集构件:CasePlanModel / Stage / HumanTask / DecisionTask / ProcessTask / CaseTask /
* Milestone / TimerEventListener / UserEventListener / PlanningTable + DiscretionaryItem /
* EntryCriterion + ExitCriterion(onPart + ifPart)/ repetitionRule / manualActivationRule / autoComplete。
*
* 自研极小 XML 读取器:只认标签、属性与嵌套 —— 不支持实体扩展、CDATA 之外的特性,
* 遇到不认识的构件**直接报错**,不静默忽略(子集封顶要能被机器证明)。
*/
export class CmmnParseError extends Error {
constructor(message: string) {
super(`CMMN 解析失败:${message}`);
this.name = 'CmmnParseError';
}
}
/* ── 极小 XML 读取器 ──────────────────────────────────────────────── */
export type XmlNode = {
readonly tag: string;
readonly attrs: Readonly<Record<string, string>>;
readonly children: readonly XmlNode[];
readonly text: string;
};
const TAG = /<(\/?)([A-Za-z_][\w.:-]*)((?:\s+[\w.:-]+\s*=\s*"[^"]*")*)\s*(\/?)>/g;
const ATTR = /([\w.:-]+)\s*=\s*"([^"]*)"/g;
export function parseXml(source: string): XmlNode {
const cleaned = source.replace(/<\?[\s\S]*?\?>/g, '').replace(/<!--[\s\S]*?-->/g, '');
const stack: Array<{ tag: string; attrs: Record<string, string>; children: XmlNode[]; text: string }> = [];
let root: XmlNode | undefined;
let lastIndex = 0;
TAG.lastIndex = 0;
for (let m = TAG.exec(cleaned); m !== null; m = TAG.exec(cleaned)) {
const [full, closing, rawTag, rawAttrs, selfClosing] = m;
const between = cleaned.slice(lastIndex, m.index).trim();
lastIndex = m.index + full.length;
const top = stack.at(-1);
if (between !== '' && top !== undefined) top.text += unescapeXml(between);
const tag = stripNamespace(rawTag!);
if (closing === '/') {
const finished = stack.pop();
if (finished === undefined) throw new CmmnParseError(`多余的结束标签 </${tag}>`);
if (finished.tag !== tag) throw new CmmnParseError(`标签不匹配:<${finished.tag}> 与 </${tag}>`);
const node: XmlNode = { ...finished, children: finished.children };
const parent = stack.at(-1);
if (parent === undefined) root = node;
else parent.children.push(node);
continue;
}
const attrs: Record<string, string> = {};
ATTR.lastIndex = 0;
for (let a = ATTR.exec(rawAttrs ?? ''); a !== null; a = ATTR.exec(rawAttrs ?? '')) {
attrs[stripNamespace(a[1]!)] = unescapeXml(a[2]!);
}
if (selfClosing === '/') {
const node: XmlNode = { tag, attrs, children: [], text: '' };
const parent = stack.at(-1);
if (parent === undefined) root = node;
else parent.children.push(node);
continue;
}
stack.push({ tag, attrs, children: [], text: '' });
}
if (stack.length > 0) throw new CmmnParseError(`标签未闭合:<${stack.at(-1)!.tag}>`);
if (root === undefined) throw new CmmnParseError('没有根元素');
return root;
}
function stripNamespace(name: string): string {
const idx = name.indexOf(':');
return idx < 0 ? name : name.slice(idx + 1);
}
function unescapeXml(text: string): string {
return text
.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&amp;/g, '&');
}
/* ── 子集构件模型 ───────────────────────────────────────────────────── */
/** 计划项类型 —— 子集全集(AM 03 构件表) */
export const PLAN_ITEM_TYPES = [
'humanTask',
'decisionTask',
'processTask',
'caseTask',
'stage',
'milestone',
'timerEventListener',
'userEventListener',
] as const;
export type PlanItemType = (typeof PLAN_ITEM_TYPES)[number];
/** 前提:onPart(听什么)+ ifPart(内容条件,S-FEEL) */
export type Criterion = {
readonly id: string;
/** 监听哪个任务数据项的写入/变更,或哪个计划项的迁移 */
readonly onParts: ReadonlyArray<{ kind: 'file' | 'plan-item'; ref: string; event: string }>;
/** 内容条件 —— 不可机算的标记为语义性前提,交 Agent 评估 */
readonly ifPart?: string;
/** 语义性前提:引擎标「待裁量评估」,不自行裁决 */
readonly semantic: boolean;
};
export type PlanItem = {
readonly id: string;
readonly name: string;
readonly type: PlanItemType;
/** 所属阶段;顶层为 undefined */
readonly parent?: string;
/** 触达世界与关键交付者**必须** manualActivationRule = true(AM 4.1 原理一) */
readonly manualActivation: boolean;
readonly repetition: boolean;
/** 裁量项:从 planning table 由 Agent 临场规划入场 */
readonly discretionary: boolean;
readonly entryCriteria: readonly Criterion[];
readonly exitCriteria: readonly Criterion[];
/** DecisionTask 引用的决策 */
readonly decisionRef?: string;
/** TimerEventListener 的时限 */
readonly timerExpression?: string;
/** 阶段的「齐了即收」 */
readonly autoComplete: boolean;
/** 调人时投递到哪个工位 */
readonly roleRef?: string;
};
export type CaseModel = {
readonly id: string;
readonly name: string;
readonly autoComplete: boolean;
readonly items: readonly PlanItem[];
/** CaseFileItem:任务数据 —— 事的数据中心 */
readonly fileItems: ReadonlyArray<{ id: string; name: string }>;
};
const ITEM_TAGS: Readonly<Record<string, PlanItemType>> = {
humanTask: 'humanTask',
decisionTask: 'decisionTask',
processTask: 'processTask',
caseTask: 'caseTask',
stage: 'stage',
milestone: 'milestone',
timerEventListener: 'timerEventListener',
userEventListener: 'userEventListener',
};
/** 结构性标签:允许出现但本身不是计划项 */
const STRUCTURAL = new Set([
'definitions',
'case',
'casePlanModel',
'caseFileModel',
'caseFileItem',
'planItem',
'planningTable',
'discretionaryItem',
'entryCriterion',
'exitCriterion',
'sentry',
'onPart',
'ifPart',
'condition',
'standardEvent',
'itemControl',
'repetitionRule',
'manualActivationRule',
'requiredRule',
'timerExpression',
'caseRoles',
'role',
'extensionElements',
'documentation',
'CMMNDI',
'CMMNDiagram',
'CMMNShape',
'CMMNEdge',
'Bounds',
'waypoint',
'CMMNLabel',
]);
export function parseCmmn(xml: string): CaseModel {
const root = parseXml(xml);
const plan = findFirst(root, 'casePlanModel');
if (plan === undefined) throw new CmmnParseError('缺 casePlanModel');
assertSubset(plan);
const sentries = new Map<string, XmlNode>();
collect(plan, 'sentry', sentries);
const items: PlanItem[] = [];
walkItems(plan, undefined, sentries, items, false);
const fileModel = findFirst(root, 'caseFileModel');
const fileItems =
fileModel === undefined
? []
: fileModel.children
.filter((c) => c.tag === 'caseFileItem')
.map((c) => ({ id: c.attrs['id'] ?? '', name: c.attrs['name'] ?? c.attrs['id'] ?? '' }));
return {
id: plan.attrs['id'] ?? 'case',
name: plan.attrs['name'] ?? '案例',
autoComplete: plan.attrs['autoComplete'] === 'true',
items,
fileItems,
};
}
/** 子集封顶的机器证明:出现表外构件即报错,不静默忽略 */
function assertSubset(node: XmlNode): void {
for (const child of node.children) {
if (ITEM_TAGS[child.tag] === undefined && !STRUCTURAL.has(child.tag)) {
throw new CmmnParseError(`构件 <${child.tag}> 不在 CMMN 子集内(子集封顶,扩构件须改 §4 并建 ADR)`);
}
assertSubset(child);
}
}
function walkItems(
node: XmlNode,
parent: string | undefined,
sentries: Map<string, XmlNode>,
out: PlanItem[],
discretionary: boolean,
): void {
for (const child of node.children) {
const type = ITEM_TAGS[child.tag];
if (type !== undefined) {
const id = child.attrs['id'] ?? '';
const control = findFirst(child, 'itemControl');
const item: PlanItem = {
id,
name: child.attrs['name'] ?? id,
type,
...(parent === undefined ? {} : { parent }),
manualActivation: hasRule(control, 'manualActivationRule') || child.attrs['manualActivation'] === 'true',
repetition: hasRule(control, 'repetitionRule') || child.attrs['repetition'] === 'true',
discretionary,
entryCriteria: criteriaOf(child, 'entryCriterion', sentries),
exitCriteria: criteriaOf(child, 'exitCriterion', sentries),
...(child.attrs['decisionRef'] === undefined ? {} : { decisionRef: child.attrs['decisionRef'] }),
...timerOf(child),
autoComplete: child.attrs['autoComplete'] === 'true',
...(child.attrs['roleRef'] === undefined ? {} : { roleRef: child.attrs['roleRef'] }),
};
out.push(item);
// 阶段展开即 create 子项;planningTable 由下面的分支统一按裁量项处理,此处不重复收集
if (type === 'stage') walkItems(child, id, sentries, out, false);
continue;
}
if (child.tag === 'planningTable') {
walkItems(child, parent, sentries, out, true);
continue;
}
if (child.tag === 'discretionaryItem') {
walkItems({ ...child, children: child.children }, parent, sentries, out, true);
continue;
}
if (STRUCTURAL.has(child.tag)) walkItems(child, parent, sentries, out, discretionary);
}
}
function criteriaOf(node: XmlNode, tag: 'entryCriterion' | 'exitCriterion', sentries: Map<string, XmlNode>): Criterion[] {
return node.children
.filter((c) => c.tag === tag)
.map((c) => {
const sentryRef = c.attrs['sentryRef'];
const sentry = sentryRef === undefined ? undefined : sentries.get(sentryRef);
const onParts = (sentry?.children ?? [])
.filter((s) => s.tag === 'onPart')
.map((s) => ({
kind: (s.attrs['caseFileItemRef'] !== undefined ? 'file' : 'plan-item') as 'file' | 'plan-item',
ref: s.attrs['caseFileItemRef'] ?? s.attrs['sourceRef'] ?? '',
event: textOf(s, 'standardEvent') ?? s.attrs['standardEvent'] ?? 'create',
}));
const ifPartNode = sentry === undefined ? undefined : findFirst(sentry, 'ifPart');
const condition = ifPartNode === undefined ? undefined : textOf(ifPartNode, 'condition') ?? ifPartNode.text.trim();
const semantic = condition !== undefined && condition.startsWith('semantic:');
return {
id: c.attrs['id'] ?? sentryRef ?? '',
onParts,
...(condition === undefined || condition === ''
? {}
: { ifPart: semantic ? condition.slice('semantic:'.length).trim() : condition }),
semantic,
} satisfies Criterion;
});
}
function timerOf(node: XmlNode): { timerExpression?: string } {
const value = textOf(node, 'timerExpression');
return value === undefined ? {} : { timerExpression: value };
}
function hasRule(control: XmlNode | undefined, rule: string): boolean {
if (control === undefined) return false;
const node = findFirst(control, rule);
if (node === undefined) return false;
const condition = textOf(node, 'condition');
return condition === undefined || condition.trim() === '' || condition.trim() === 'true';
}
function findFirst(node: XmlNode, tag: string): XmlNode | undefined {
if (node.tag === tag) return node;
for (const child of node.children) {
const found = findFirst(child, tag);
if (found !== undefined) return found;
}
return undefined;
}
function collect(node: XmlNode, tag: string, out: Map<string, XmlNode>): void {
if (node.tag === tag) {
const id = node.attrs['id'];
if (id !== undefined) out.set(id, node);
}
for (const child of node.children) collect(child, tag, out);
}
function textOf(node: XmlNode, tag: string): string | undefined {
const found = node.children.find((c) => c.tag === tag);
if (found === undefined) return undefined;
const text = found.text.trim();
return text === '' ? undefined : text;
}
@@ -0,0 +1,138 @@
/**
* DMN 求值 —— 纯逻辑,零 IO(编码规范 §5.3 / §4:DMN 落点在本模块 domain,
* 单元格谓词复用 `@sfeel`;语义出处 AM 3.3)。
*
* 「DMN 承担一件事:把装得下的判断**从 LLM 手里拿走**。
* 能算的不问 LLM(必须)—— 输入齐备的决策表由语义引擎确定性求值,同输入必同结论,零方差零 token;
* DRD 即缺口演绎(应)—— 输入不齐时,DRD 指出缺什么;
* 决策留痕(必须)—— 输入快照 + 决策表版本 + **命中规则行**;
* 结论不可静默覆写(必须)。」
*/
import type { DmnDecisionTable, DmnModel } from '@contracts';
export type UnaryTestEvaluator = (cell: string, value: unknown, scope: Readonly<Record<string, unknown>>) => boolean;
export type ExpressionEvaluator = (expression: string, scope: Readonly<Record<string, unknown>>) => unknown;
/** 决策留痕:输入快照 + 决策表版本 + 命中规则行 —— 决策可回放、可归因 */
export type DecisionTrace = {
readonly decision: string;
readonly tableId: string;
readonly inputs: Readonly<Record<string, unknown>>;
readonly hitRules: readonly string[];
readonly outputs: Readonly<Record<string, unknown>>;
};
export type DecisionResult =
| { readonly kind: 'decided'; readonly trace: DecisionTrace }
/** 输入不齐 —— DRD 指出缺什么(缺口演绎的后拉遍历) */
| { readonly kind: 'incomplete'; readonly missing: readonly string[] }
/** 无规则命中 */
| { readonly kind: 'no-match'; readonly inputs: Readonly<Record<string, unknown>> };
/** 求值一张决策表 */
export function evaluateTable(
table: DmnDecisionTable,
scope: Readonly<Record<string, unknown>>,
evaluateExpression: ExpressionEvaluator,
evaluateUnaryTest: UnaryTestEvaluator,
): DecisionResult {
const inputs: Record<string, unknown> = {};
const missing: string[] = [];
for (const input of table.inputs) {
const value = evaluateExpression(input.expression, scope);
if (value === undefined) missing.push(input.name);
inputs[input.name] = value;
}
if (missing.length > 0) return { kind: 'incomplete', missing };
const hitRules: string[] = [];
const outputs: Record<string, unknown> = {};
for (const rule of table.rules) {
const matches = table.inputs.every((input, index) => {
const cell = rule.when[index] ?? '-';
return evaluateUnaryTest(cell, inputs[input.name], scope);
});
if (!matches) continue;
hitRules.push(rule.id);
for (const [index, output] of table.outputs.entries()) {
const value = rule.then[index];
if (table.hitPolicy === 'COLLECT') {
const bucket = (outputs[output.name] as unknown[] | undefined) ?? [];
bucket.push(value);
outputs[output.name] = bucket;
} else {
// UNIQUE / FIRST / PRIORITY:先命中者定结论
if (!(output.name in outputs)) outputs[output.name] = value;
}
}
if (table.hitPolicy === 'FIRST' || table.hitPolicy === 'UNIQUE') break;
}
if (hitRules.length === 0) return { kind: 'no-match', inputs };
return { kind: 'decided', trace: { decision: table.id, tableId: table.id, inputs, hitRules, outputs } };
}
/* ── DRD:缺口演绎 ─────────────────────────────────────────────────── */
export type Gap =
/** 可算:先算上游决策 */
| { readonly kind: 'decision'; readonly ref: string }
/** 可查 / 只有人知道 / 在世界侧 —— 由 Agent 按断点判定去处 */
| { readonly kind: 'input'; readonly ref: string };
/**
* 沿 DRD 反向遍历:目标决策缺什么 → 缺口由什么推出。
* 「主要就是走 DRD」—— 双向驱动的后拉遍历。
*/
export function deduceGaps(model: DmnModel, decision: string, known: ReadonlySet<string>): Gap[] {
const node = model.drd.find((d) => d.decision === decision);
if (node === undefined) return [];
const gaps: Gap[] = [];
for (const required of node.requires) {
if (known.has(required)) continue;
gaps.push({ kind: 'decision', ref: required });
gaps.push(...deduceGaps(model, required, known));
}
for (const input of node.inputs) {
if (!known.has(input)) gaps.push({ kind: 'input', ref: input });
}
// 去重,保持首次出现次序
const seen = new Set<string>();
return gaps.filter((g) => {
const key = `${g.kind}:${g.ref}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
/** 拓扑序:先算上游决策,再算目标 */
export function evaluationOrder(model: DmnModel, target: string): string[] {
const order: string[] = [];
const visiting = new Set<string>();
const visit = (decision: string): void => {
if (order.includes(decision)) return;
if (visiting.has(decision)) throw new Error(`DRD 成环:${decision}`);
visiting.add(decision);
const node = model.drd.find((d) => d.decision === decision);
for (const required of node?.requires ?? []) visit(required);
visiting.delete(decision);
order.push(decision);
};
visit(target);
return order;
}
/**
* 结论不可静默覆写(必须):
* DMN 结论对 Agent 是**约束性输入** —— 认为不合理时只有两条路:
* 超出授权走「人 · 决策」,规则本身有病提迭代提案。
*/
export function isBindingConclusion(result: DecisionResult): result is Extract<DecisionResult, { kind: 'decided' }> {
return result.kind === 'decided';
}
@@ -0,0 +1,281 @@
/**
* CMMN 标准生命周期与双向驱动 —— 纯逻辑,零 IO(编码规范 §5.3;语义出处 AM 04)。
*
* 「状态的权威是 CMMN 规范的标准生命周期:**活动不定义状态,模型不发明状态**;
* 引擎按规范推进,守卫(时限 / 预算 / 预授权 / 监听)仅可参数化。」
*/
import type { CaseModel, Criterion, PlanItem } from './cmmn-parse.ts';
/* ── 两副状态表(AM 4.1) ────────────────────────────────────────────── */
/** 案计划(整案)五态 */
export const CASE_STATES = ['Active', 'Completed', 'Suspended', 'Terminated', 'Failed'] as const;
export type CaseState = (typeof CASE_STATES)[number];
/** 计划项八态 */
export const ITEM_STATES = [
'Available',
'Enabled',
'Disabled',
'Active',
'Suspended',
'Failed',
'Completed',
'Terminated',
] as const;
export type ItemState = (typeof ITEM_STATES)[number];
/** 迁移拓扑 —— 引擎不得私改 */
const ITEM_TRANSITIONS: Readonly<Record<ItemState, readonly ItemState[]>> = {
Available: ['Enabled', 'Active', 'Terminated', 'Suspended'],
Enabled: ['Active', 'Disabled', 'Terminated', 'Suspended'],
Disabled: ['Enabled', 'Terminated'],
Active: ['Completed', 'Failed', 'Suspended', 'Terminated'],
Suspended: ['Available', 'Enabled', 'Active', 'Terminated'],
Failed: ['Active', 'Terminated'],
Completed: [],
Terminated: [],
};
const CASE_TRANSITIONS: Readonly<Record<CaseState, readonly CaseState[]>> = {
Active: ['Completed', 'Suspended', 'Terminated', 'Failed'],
Suspended: ['Active', 'Terminated'],
Failed: ['Active', 'Terminated'],
Completed: [],
Terminated: [],
};
export class IllegalTransitionError extends Error {
constructor(
readonly from: string,
readonly to: string,
) {
super(`生命周期迁移非法:${from} → ${to}(语义忠实:引擎不得私改迁移拓扑)`);
this.name = 'IllegalTransitionError';
}
}
export function canTransitionItem(from: ItemState, to: ItemState): boolean {
return ITEM_TRANSITIONS[from].includes(to);
}
export function assertItemTransition(from: ItemState, to: ItemState): void {
if (!canTransitionItem(from, to)) throw new IllegalTransitionError(from, to);
}
export function canTransitionCase(from: CaseState, to: CaseState): boolean {
return CASE_TRANSITIONS[from].includes(to);
}
/* ── 运行时快照 ─────────────────────────────────────────────────────── */
export type ItemRuntime = {
readonly id: string;
readonly state: ItemState;
/** repetitionRule 的实例序号 */
readonly instance: number;
/** 语义性前提待裁量评估的标记 */
readonly awaitingJudgement?: boolean;
};
export type CaseRuntime = {
readonly state: CaseState;
readonly items: readonly ItemRuntime[];
/** 已达成的里程碑 */
readonly milestones: readonly string[];
};
export function initialRuntime(model: CaseModel): CaseRuntime {
return {
state: 'Active',
items: model.items.map((item) => ({
id: item.id,
// 裁量项不随阶段自动入场:从 planning table 由 Agent 临场规划
state: item.discretionary ? 'Available' : 'Available',
instance: 1,
})),
milestones: [],
};
}
/* ── 前提求值 ───────────────────────────────────────────────────────── */
export type ConditionEvaluator = (expression: string, scope: Readonly<Record<string, unknown>>) => boolean;
export type CriterionVerdict =
| { readonly kind: 'satisfied' }
| { readonly kind: 'unsatisfied' }
/** 语义性前提不可机算 —— 标「待裁量评估」,交上层 Agent,引擎**不代裁量** */
| { readonly kind: 'needs-judgement'; readonly statement: string };
export function evaluateCriterion(
criterion: Criterion,
scope: Readonly<Record<string, unknown>>,
changedRefs: ReadonlySet<string>,
evaluate: ConditionEvaluator,
): CriterionVerdict {
// onPart:监听的数据项写入 / 计划项迁移发生过才算「件到」
if (criterion.onParts.length > 0) {
const allArrived = criterion.onParts.every((part) => changedRefs.has(part.ref));
if (!allArrived) return { kind: 'unsatisfied' };
}
if (criterion.ifPart === undefined) return { kind: 'satisfied' };
if (criterion.semantic) return { kind: 'needs-judgement', statement: criterion.ifPart };
return evaluate(criterion.ifPart, scope) ? { kind: 'satisfied' } : { kind: 'unsatisfied' };
}
/* ── Enabled 集发布:引擎推进的终点,Agent 裁量的起点 ────────────────── */
export type EnabledEntry = {
readonly id: string;
readonly name: string;
readonly type: PlanItem['type'];
/** manualActivationRule = true:进入 Active 永远出自 Agent 的裁量启动 */
readonly requiresManualStart: boolean;
readonly discretionary: boolean;
};
export type Redlight = { readonly criterion: string; readonly statement: string };
export type EnabledSet = {
readonly entries: readonly EnabledEntry[];
/** 待裁量评估的语义性前提 */
readonly judgements: readonly Redlight[];
/** 目标缺口:未绿的判据 */
readonly gaps: readonly string[];
/** 阶段位置:Active 的阶段 */
readonly activeStages: readonly string[];
};
/**
* 前提前推(开展):任务视图变化 → 前提满足 → 新计划项 Enabled。
* 「**Enabled ≠ Active**」—— 引擎按规范推进到 Enabled 为止;
* 凡触达世界或产出关键交付的计划项必须 manualActivationRule = true。
*/
export function publishEnabled(
model: CaseModel,
runtime: CaseRuntime,
scope: Readonly<Record<string, unknown>>,
changedRefs: ReadonlySet<string>,
evaluate: ConditionEvaluator,
unmetCriteria: readonly string[] = [],
): EnabledSet {
const byId = new Map(runtime.items.map((i) => [i.id, i]));
const entries: EnabledEntry[] = [];
const judgements: Redlight[] = [];
for (const item of model.items) {
const state = byId.get(item.id)?.state ?? 'Available';
if (state !== 'Available' && state !== 'Enabled') continue;
// 父阶段未激活的项不入场
if (item.parent !== undefined && byId.get(item.parent)?.state !== 'Active') continue;
let satisfied = true;
for (const criterion of item.entryCriteria) {
const verdict = evaluateCriterion(criterion, scope, changedRefs, evaluate);
if (verdict.kind === 'needs-judgement') {
judgements.push({ criterion: criterion.id, statement: verdict.statement });
satisfied = false;
break;
}
if (verdict.kind === 'unsatisfied') {
satisfied = false;
break;
}
}
if (!satisfied) continue;
entries.push({
id: item.id,
name: item.name,
type: item.type,
requiresManualStart: item.manualActivation,
discretionary: item.discretionary,
});
}
return {
entries,
judgements,
gaps: unmetCriteria,
activeStages: runtime.items
.filter((i) => i.state === 'Active' && model.items.find((m) => m.id === i.id)?.type === 'stage')
.map((i) => i.id),
};
}
/**
* 判据后拉(完成):目标缺口 → 由什么推出 → 落到一个 Enabled 的计划项。
* 「导航即交集,断点即人环:下一步 = 当下可为(Enabled 集)∩ 目标所需;
* **交集为空即断点** —— 调人或立监听的判定时机。」
*/
export type Navigation =
| { readonly kind: 'proceed'; readonly candidates: readonly EnabledEntry[] }
/** 断点:目标所需的缺口无法由任何 Enabled 项补足 */
| { readonly kind: 'breakpoint'; readonly gaps: readonly string[] }
| { readonly kind: 'converged' };
export function navigate(enabled: EnabledSet, required: ReadonlySet<string>): Navigation {
if (enabled.gaps.length === 0) return { kind: 'converged' };
const intersection =
required.size === 0 ? enabled.entries : enabled.entries.filter((e) => required.has(e.id));
if (intersection.length === 0) return { kind: 'breakpoint', gaps: enabled.gaps };
return { kind: 'proceed', candidates: intersection };
}
/* ── 自动完成与终局 ─────────────────────────────────────────────────── */
/** 「齐了即收」(AutoComplete):必做项完成且无进行中项即结案 */
export function autoCompleteReady(model: CaseModel, runtime: CaseRuntime): boolean {
if (!model.autoComplete) return false;
const byId = new Map(runtime.items.map((i) => [i.id, i]));
const required = model.items.filter((i) => !i.discretionary && i.type !== 'timerEventListener');
const allDone = required.every((i) => {
const state = byId.get(i.id)?.state;
return state === 'Completed' || state === 'Terminated' || state === 'Disabled';
});
const noneRunning = runtime.items.every((i) => i.state !== 'Active');
return allDone && noneRunning;
}
/**
* 终局唯放行与 Harness(必须):
* 案计划 Completed **必经放行卡**;Terminated 只出自 Harness 与负责人;
* **AI 不得产生终局迁移**。
*/
export const TERMINAL_AUTHORITIES = ['release', 'harness', 'owner', 'governance'] as const;
export type TerminalAuthority = (typeof TERMINAL_AUTHORITIES)[number];
export function mayCompleteCase(by: TerminalAuthority): boolean {
return by === 'release';
}
export function mayTerminateCase(by: TerminalAuthority): boolean {
return by === 'harness' || by === 'owner' || by === 'governance';
}
/** 里程碑与事件监听器只有一条主迁移:Available →(occur)→ Completed */
export function occur(runtime: CaseRuntime, itemId: string): CaseRuntime {
return {
...runtime,
items: runtime.items.map((i) => (i.id === itemId ? { ...i, state: 'Completed' } : i)),
milestones: runtime.milestones.includes(itemId) ? runtime.milestones : [...runtime.milestones, itemId],
};
}
export function transitionItem(runtime: CaseRuntime, itemId: string, to: ItemState): CaseRuntime {
const current = runtime.items.find((i) => i.id === itemId);
if (current === undefined) throw new IllegalTransitionError('(不存在)', to);
assertItemTransition(current.state, to);
return { ...runtime, items: runtime.items.map((i) => (i.id === itemId ? { ...i, state: to } : i)) };
}
/** repetitionRule:驳回是**迁移不是状态** —— 相关计划项以新实例重新入场 */
export function reenter(runtime: CaseRuntime, itemId: string): CaseRuntime {
return {
...runtime,
items: runtime.items.map((i) =>
i.id === itemId ? { id: i.id, state: 'Available', instance: i.instance + 1 } : i,
),
};
}
@@ -0,0 +1,322 @@
/**
* loop 回合驱动 —— 语义引擎 × Agent 回路(编码规范 §5.3 / §6.3;语义出处 AM 04)。
*
* 定位 → 择向 → 决断 → 行动 → 写数据 → 回到定位。
* **回合由 usecase 驱动,不放开自由 maxSteps;预算在环外由 Harness 强制。**
*/
import type { LoopModel, TaskId } from '@contracts';
import type { AgentAction, LoopEngineService, TurnOutcome } from '../../contract.ts';
import { ForbiddenTerminalError } from '../../contract.ts';
import type {
AgentPort,
CardGateway,
Clock,
ListenerRegistrar,
LoopStateStore,
MemoryRecorder,
Sfeel,
TaskContext,
TaskDataGateway,
ToolGateway,
} from '../../port.ts';
import { parseCmmn } from '../domain/cmmn-parse.ts';
import type { CaseRuntime } from '../domain/lifecycle.ts';
import {
autoCompleteReady,
initialRuntime,
navigate,
occur,
publishEnabled,
reenter as reenterItem,
transitionItem,
} from '../domain/lifecycle.ts';
import { evaluateTable, evaluationOrder } from '../domain/dmn.ts';
export type TurnRunnerDeps = {
readonly state: LoopStateStore;
readonly sfeel: Sfeel;
readonly data: TaskDataGateway;
readonly agent: AgentPort;
readonly tools: ToolGateway;
readonly cards: CardGateway;
readonly listeners: ListenerRegistrar;
readonly memory: MemoryRecorder;
readonly tasks: TaskContext;
readonly clock: Clock;
};
export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
async function loaded(taskId: TaskId) {
const found = await deps.state.load(taskId);
if (found === undefined) throw new Error(`loop 未装载:${taskId}`);
return found;
}
/** 变化面:任务视图里有值的键 —— onPart 的「件到」判据 */
function changedRefs(scope: Record<string, unknown>): Set<string> {
return new Set(Object.keys(scope).filter((k) => scope[k] !== undefined && scope[k] !== null));
}
async function publishFor(taskId: TaskId) {
const { model, parsed, runtime } = await loaded(taskId);
const scope = await deps.data.view(taskId);
const gaps = parsed.items
.filter((i) => !i.discretionary && runtime.items.find((r) => r.id === i.id)?.state !== 'Completed')
.map((i) => i.name);
return {
model,
parsed,
runtime,
scope,
enabled: publishEnabled(parsed, runtime, scope, changedRefs(scope), deps.sfeel.condition, gaps),
};
}
async function applyAction(taskId: TaskId, action: AgentAction, runtime: CaseRuntime): Promise<{ runtime: CaseRuntime; status: TurnOutcome['status']; reason?: string }> {
const context = await deps.tasks.of(taskId);
switch (action.kind) {
case 'start': {
// manualStart —— 进入 Active 永远出自 Agent 的裁量启动,留痕
await deps.memory.append({
taskId,
kind: 'engine-transition',
title: `manualStart:${action.itemId}`,
detail: { by: 'agent' },
});
return { runtime: transitionItem(runtime, action.itemId, 'Active'), status: 'running' };
}
case 'plan': {
await deps.memory.append({ taskId, kind: 'engine-transition', title: `规划裁量项入场:${action.itemId}` });
return { runtime: transitionItem(runtime, action.itemId, 'Enabled'), status: 'running' };
}
case 'disable': {
await deps.memory.append({
taskId,
kind: 'engine-transition',
title: `弃用计划项:${action.itemId}`,
detail: { reason: action.reason },
});
return { runtime: transitionItem(runtime, action.itemId, 'Disabled'), status: 'running' };
}
case 'judge': {
// 语义性前提的裁量评估 —— 评估留痕,可回放可归因
await deps.memory.append({
taskId,
kind: 'evaluation',
title: `语义性前提评估:${action.criterion} → ${action.satisfied ? '满足' : '未满足'}`,
detail: { rationale: action.rationale },
});
return { runtime, status: 'running' };
}
case 'tool': {
// 写类工具登记为动作意向 —— 触达世界的动作集中在放行时落地
if (await deps.tools.isWriteEffect(action.tool)) {
await deps.tools.registerIntent({
taskId,
tool: action.tool,
input: action.input,
label: `${action.itemId}:${action.tool}`,
});
return { runtime: transitionItem(runtime, action.itemId, 'Completed'), status: 'running' };
}
const result = await deps.tools.invoke({
tool: action.tool,
input: action.input,
activityId: context.activityId,
taskId,
});
await deps.memory.append({
taskId,
kind: 'result',
title: `${action.tool} ${result.ok ? '返回' : '失败'}`,
detail: { output: result.output, error: result.error?.message },
});
return {
runtime: transitionItem(runtime, action.itemId, result.ok ? 'Completed' : 'Failed'),
status: 'running',
};
}
case 'human': {
// 调人 = 立监听听人:调用即 Active(阻塞),回传驱动前提
const card = await deps.cards.issue({
taskId,
ownerId: context.ownerId,
humanCallCap: context.humanCallCap,
workOrder: action.workOrder,
card: action.card,
});
await deps.listeners.register({
taskId,
eventType: 'human',
keyFilter: { cardId: card.id },
deadline: 'P3D',
});
return { runtime: transitionItem(runtime, action.itemId, 'Active'), status: 'awaiting' };
}
case 'listen': {
// AI 不能调用世界:对世界只有两种合法姿势 —— 触达它(动作)与等它(监听)
await deps.listeners.register({
taskId,
eventType: action.eventType,
keyFilter: action.keyFilter,
deadline: action.deadline,
});
await deps.memory.append({
taskId,
kind: 'engine-transition',
title: `立监听:${action.eventType}(时限 ${action.deadline})`,
detail: action.keyFilter,
});
return { runtime, status: 'awaiting' };
}
case 'submit': {
await deps.memory.append({
taskId,
kind: 'thought',
title: '判定成果已齐,提交放行',
detail: { rationale: action.rationale },
});
// 终局唯放行:引擎不把案计划置为 Completed —— 那是 release 的事
return { runtime, status: 'submitted' };
}
}
}
return {
async load(taskId, model: LoopModel) {
const parsed = parseCmmn(model.cmmnXml);
const runtime = initialRuntime(parsed);
await deps.state.save(taskId, model, parsed, runtime);
await deps.memory.append({
taskId,
kind: 'engine-transition',
title: `装载案例模型:${parsed.name}(${parsed.items.length} 个计划项)`,
});
return runtime;
},
async runtime(taskId) {
return (await deps.state.load(taskId))?.runtime;
},
async publish(taskId) {
return (await publishFor(taskId)).enabled;
},
/** 一个回合:定位 → 择向 → 决断 → 行动 → 写数据 */
async runTurn(taskId): Promise<TurnOutcome> {
const { model, parsed, runtime, scope, enabled } = await publishFor(taskId);
// 齐了即收:必做项完成且无进行中项 → 提交放行(仍必经放行卡)
if (autoCompleteReady(parsed, runtime)) {
return { runtime, enabled, navigation: { kind: 'converged' }, status: 'submitted' };
}
// 导航即交集:当下可为 ∩ 目标所需
const navigation = navigate(enabled, new Set(enabled.entries.map((e) => e.id)));
if (navigation.kind === 'breakpoint') {
// 断点即人环 —— 由 Agent 定夺调人还是立监听,判定留痕
await deps.memory.append({
taskId,
kind: 'thought',
title: '依赖断点:当下可为 ∩ 目标所需 = 空',
detail: { gaps: navigation.gaps },
});
}
const narrative = [
model.narrative,
...(await deps.memory.loadActivityMemory((await deps.tasks.of(taskId)).activityId, 20)).map(
(m) => `[${m.layer}] ${m.statement}`,
),
].join('\n\n');
const action = await deps.agent.decide({ taskId, narrative, enabled, scope });
// AI 不得产生终局迁移
if (action.kind === 'start') {
const item = parsed.items.find((i) => i.id === action.itemId);
if (item === undefined) throw new ForbiddenTerminalError('owner');
}
const applied = await applyAction(taskId, action, runtime);
await deps.state.save(taskId, model, parsed, applied.runtime);
return {
runtime: applied.runtime,
enabled,
navigation,
action,
status: applied.status,
...(applied.reason === undefined ? {} : { blockedReason: applied.reason }),
};
},
/** 事件驱动续跑:挂起期间事件不丢,恢复即补投 */
async onEvent(taskId, event) {
const { model, parsed, runtime } = await loaded(taskId);
await deps.memory.append({
taskId,
kind: 'observation',
title: `事件到达(${event.kind}):${event.ref}`,
detail: event.payload,
});
let next = runtime;
// 里程碑与事件监听器只有一条主迁移:Available →(occur)→ Completed
const listener = parsed.items.find((i) => i.id === event.ref && i.type.endsWith('EventListener'));
if (listener !== undefined) next = occur(runtime, listener.id);
await deps.state.save(taskId, model, parsed, next);
// 前提前推:事件写数据 → 任务视图变化 → 前提求值 → 新项 Enabled
return this.runTurn(taskId);
},
/** 能算的不问 LLM:输入齐备的决策表确定性求值,命中行留痕 */
async decide(taskId, decision) {
const { model } = await loaded(taskId);
const scope = await deps.data.view(taskId);
const order = evaluationOrder(model.dmn, decision);
const enriched: Record<string, unknown> = { ...scope };
let last = undefined as ReturnType<typeof evaluateTable> | undefined;
for (const step of order) {
const table = model.dmn.tables.find((t) => t.id === step);
if (table === undefined) continue;
const result = evaluateTable(table, enriched, deps.sfeel.expression, deps.sfeel.unaryTest);
last = result;
if (result.kind !== 'decided') break;
Object.assign(enriched, result.trace.outputs);
await deps.memory.append({
taskId,
kind: 'evaluation',
title: `DMN 求值:${table.label} → 命中 ${result.trace.hitRules.join('、')}`,
detail: { inputs: result.trace.inputs, outputs: result.trace.outputs, table: table.id },
});
}
return last ?? { kind: 'no-match', inputs: {} };
},
/** 驳回:相关计划项以新实例重新入场(repetitionRule),记为纠正信号 */
async reenter(taskId, itemId) {
const { model, parsed, runtime } = await loaded(taskId);
const next = reenterItem(runtime, itemId);
await deps.state.save(taskId, model, parsed, next);
await deps.memory.append({
taskId,
kind: 'engine-transition',
title: `驳回重入:${itemId}(新实例)`,
});
return next;
},
};
}
@@ -0,0 +1,7 @@
{
"name": "@modules/loop-engine",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": { ".": "./index.ts" }
}
+93
View File
@@ -0,0 +1,93 @@
/**
* module: loop-engine —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3)。
*/
import type { LoopModel, StaffId, TaskId } from '@contracts';
import type { CaseModel } from './internal/domain/cmmn-parse.ts';
import type { CaseRuntime, EnabledSet } from './internal/domain/lifecycle.ts';
import type { AgentAction } from './contract.ts';
export type Clock = { now(): Date };
/** 运行时实例状态 —— loop 的模型定位与就绪集(OS 09 三份持久状态之一) */
export type LoopStateStore = {
save(taskId: TaskId, model: LoopModel, parsed: CaseModel, runtime: CaseRuntime): Promise<void>;
load(taskId: TaskId): Promise<{ model: LoopModel; parsed: CaseModel; runtime: CaseRuntime } | undefined>;
};
/** S-FEEL —— app 注入 `@sfeel`;引擎零 LLM */
export type Sfeel = {
condition(expression: string, scope: Readonly<Record<string, unknown>>): boolean;
expression(expression: string, scope: Readonly<Record<string, unknown>>): unknown;
unaryTest(cell: string, value: unknown, scope: Readonly<Record<string, unknown>>): boolean;
};
/** 任务数据 —— app 注入 `ledger`(L1):引擎对**任务视图**求值,不依赖事件 */
export type TaskDataGateway = {
view(taskId: TaskId): Promise<Record<string, unknown>>;
draftWrite(input: {
taskId: TaskId;
domain: string;
object: string;
key: Record<string, string | number>;
patch: Record<string, unknown>;
}): Promise<void>;
};
/**
* Agent 回路 —— app 注入(Mastra Agent 在 adapter,§6.1)。
* **回合由 usecase 驱动,不放开自由 maxSteps,预算在环外由 Harness 强制。**
*/
export type AgentPort = {
decide(input: {
taskId: TaskId;
/** 叙事面:说明书 + 活动记忆选摘 + 任务记忆窗口 */
narrative: string;
enabled: EnabledSet;
scope: Record<string, unknown>;
}): Promise<AgentAction>;
};
/** 工具 / 人类工具 / 监听 —— 三型行动的落点 */
export type ToolGateway = {
invoke(input: { tool: string; input: Record<string, unknown>; activityId: string; taskId: TaskId }): Promise<{
ok: boolean;
output?: Record<string, unknown>;
error?: { message: string };
}>;
isWriteEffect(tool: string): Promise<boolean>;
registerIntent(input: { taskId: TaskId; tool: string; input: Record<string, unknown>; label: string }): Promise<void>;
};
export type CardGateway = {
issue(input: {
taskId: TaskId;
ownerId: StaffId;
humanCallCap: number;
workOrder: 'execution' | 'acceptance';
card: Record<string, unknown>;
}): Promise<{ id: string }>;
};
export type ListenerRegistrar = {
register(input: {
taskId: TaskId;
eventType: 'data' | 'time' | 'human';
keyFilter: Record<string, string>;
deadline: string;
}): Promise<{ id: string }>;
};
export type MemoryRecorder = {
append(input: {
taskId: TaskId;
kind: 'engine-transition' | 'evaluation' | 'thought' | 'action' | 'result' | 'observation';
title: string;
detail?: Record<string, unknown>;
}): Promise<void>;
/** 装载:按定位选摘活动记忆入 Agent 上下文(渐进披露) */
loadActivityMemory(activityId: string, limit: number): Promise<Array<{ layer: string; statement: string }>>;
};
export type TaskContext = {
of(taskId: TaskId): Promise<{ activityId: string; ownerId: StaffId; humanCallCap: number }>;
};
@@ -0,0 +1,27 @@
# module: skill-engine
职责: skill 范式:技能目录的渐进披露、装配前的扩权检查、结晶片段脚本(确定性动作链)的运行、
装配信封的戳
不负责: 技能的存放与版本(见 activity-registry,技能是活动的资产之一)、
工具执行(见 toolbox 的 ToolInvoker)、何时装配哪些技能(loop 临场 / flow 步引用)
公开出口: index.ts
- 类型:`SkillEngineService` `RunScriptInput` `ScriptOutcome` `SkillIndexEntry` `SkillStamp`
- 错误:`SkillPowerEscalationError`
- 端口类型:`SkillCatalog` `ScriptToolInvoker` `MemoryRecorder` `Clock`
- 用例工厂:`createSkillEngineService(deps): SkillEngineService`
- 纯函数:`checkSkillPower` `checkCatalog` `skillIndex` `stampOf` `crystallizedChain` `interpolate`
层级: L4(编码规范 §4;置于 L4 因 loop/flow 两范式依赖它 —— 临场装配 / 步引用,
import 恒向下、免 port)
依赖: @contracts;port:`SkillCatalog`(app 注入 activity-registry)、
`ScriptToolInvoker`(app 注入 toolbox)、`MemoryRecorder`(app 注入 memory)
被依赖: loop-engine, flow-engine, task
不变量(AM 08):
- **载方法,不载权力**:结晶片段引用白名单外的工具即 `SkillPowerEscalationError`;
技能不得扩大白名单、授权边界与禁区
- **脚本按纯工具规矩**:每步经 `ToolInvoker` 调用而留痕 —— 不得内联绕过,不得成为绕过留痕的暗道
- **装配入信封**:`stamp()` 给出技能 ID × 版本,记入任务记忆 —— 回放与归因依赖之
- **渐进披露**:常驻上下文只有 `index()` 的目录;正文按事 `load()`
- 技能**不是独立范式**:无自己的约束体系,一切继承本规范
@@ -0,0 +1,50 @@
/**
* module: skill-engine —— 对外类型(编码规范 §10.1;语义出处 AM 08)。
*
* 技能不是独立范式:没有自己的约束体系 —— 留痕、治理、权力边界与生命周期全部继承本规范。
* **内容软**(方法正文自由,用不用由 AI 决断),**边框硬**(装配入信封 · 脚本注册留痕 ·
* 不得扩权 · 版本过进化关口)。
*/
import type { Skill, TaskId } from '@contracts';
import type {
AssemblyContext,
CrystallizedStep,
SkillIndexEntry,
SkillStamp,
SkillViolation,
} from './internal/domain/skill-assembly.ts';
export type { AssemblyContext, CrystallizedStep, Skill, SkillIndexEntry, SkillStamp, SkillViolation };
export class SkillPowerEscalationError extends Error {
constructor(readonly violations: readonly SkillViolation[]) {
super(`技能试图扩权:${violations.map((v) => v.message).join('; ')}`);
this.name = 'SkillPowerEscalationError';
}
}
export type RunScriptInput = {
readonly taskId: TaskId;
readonly activityId: string;
readonly skillId: string;
/** 任务数据 —— 结晶片段的入参插值面 */
readonly scope: Readonly<Record<string, unknown>>;
readonly stepNo?: number;
};
export type ScriptOutcome = {
readonly ok: boolean;
readonly steps: ReadonlyArray<{ tool: string; ok: boolean; output?: Record<string, unknown>; error?: string }>;
readonly stamp: readonly SkillStamp[];
};
export type SkillEngineService = {
/** 常驻上下文只有目录 —— 渐进披露 */
index(activityId: string): Promise<SkillIndexEntry[]>;
/** 按事加载正文;装配前过扩权检查 */
load(activityId: string, skillIds: readonly string[]): Promise<Skill[]>;
/** 结晶片段脚本 = 确定性动作链;经 ToolInvoker 调用而留痕,不得内联绕过 */
runScript(input: RunScriptInput): Promise<ScriptOutcome>;
/** 装配入信封 —— 供任务记忆盖戳 */
stamp(activityId: string, skillIds: readonly string[]): Promise<SkillStamp[]>;
};
@@ -0,0 +1,29 @@
/**
* module: skill-engine —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。
*/
export type {
AssemblyContext,
CrystallizedStep,
RunScriptInput,
ScriptOutcome,
Skill,
SkillEngineService,
SkillIndexEntry,
SkillStamp,
SkillViolation,
} from './contract.ts';
export { SkillPowerEscalationError } from './contract.ts';
export type { Clock, MemoryRecorder, ScriptToolInvoker, SkillCatalog } from './port.ts';
export type { SkillEngineDeps } from './internal/usecase/skill-engine-service.ts';
export { createSkillEngineService } from './internal/usecase/skill-engine-service.ts';
export {
checkCatalog,
checkSkillPower,
crystallizedChain,
interpolate,
skillIndex,
stampOf,
} from './internal/domain/skill-assembly.ts';
@@ -0,0 +1,91 @@
/**
* 技能装配 —— 纯逻辑,零 IO(编码规范 §5.3;语义出处 AM 08)。
*
* 「白名单回答『能用什么』(权力),技能回答『怎么用好』(方法)—— 棋子确定,棋谱是资产。」
*
* 三层软硬:
* 工具白名单 = 硬约束(权力,契约定)—— 不在名单调不动
* 技能清单 = 软资源(方法,包声明)—— AI 可用可不用,**可以越谱,不可越权**
* 临场路径 = 自由(由事决定,loop)—— 受上两层约束
*/
import type { Skill } from '@contracts';
export type AssemblyContext = {
/** 本活动的工具白名单 —— 硬约束 */
readonly allowlist: readonly string[];
/** 本活动可装配的技能集合 —— 软资源 */
readonly catalog: readonly Skill[];
};
export type SkillViolation = { readonly skillId: string; readonly tool: string; readonly message: string };
/**
* 载方法,不载权力(必须)——
* 「技能不得扩大白名单、授权边界与禁区;技能提及的工具不在本活动白名单者,**不可调**。」
*/
export function checkSkillPower(skill: Skill, allowlist: readonly string[]): SkillViolation[] {
return skill.crystallized
.filter((step) => !allowlist.includes(step.tool))
.map((step) => ({
skillId: skill.id,
tool: step.tool,
message: `技能 ${skill.label} 的结晶片段引用了白名单外的工具 ${step.tool} —— 载方法不载权力`,
}));
}
export function checkCatalog(context: AssemblyContext): SkillViolation[] {
return context.catalog.flatMap((skill) => checkSkillPower(skill, context.allowlist));
}
/** 技能目录:常驻上下文里只有它 —— 正文按事加载,引用按需下钻(渐进披露) */
export type SkillIndexEntry = { readonly id: string; readonly label: string; readonly applicability: string };
export function skillIndex(catalog: readonly Skill[]): SkillIndexEntry[] {
return catalog.map((s) => ({ id: s.id, label: s.label, applicability: s.applicability }));
}
/** 装配入信封:每步装配了哪些技能及其版本,记入任务记忆 —— 回放与归因依赖之 */
export type SkillStamp = { readonly id: string; readonly version: string };
export function stampOf(skills: readonly Skill[]): SkillStamp[] {
return skills.map((s) => ({ id: s.id, version: s.version }));
}
/**
* 结晶片段脚本 = 确定性动作链。
* 「脚本必须**注册进工具表、经运行时调用而留痕**、无环境能力;不得成为绕过留痕的暗道。」
*/
export type CrystallizedStep = { readonly tool: string; readonly input: Readonly<Record<string, string>> };
export function crystallizedChain(skill: Skill): CrystallizedStep[] {
return skill.crystallized.map((s) => ({ tool: s.tool, input: s.input }));
}
/** 入参插值:`{{path}}` 从任务数据取值 —— 纯字符串替换,零求值 */
export function interpolate(
input: Readonly<Record<string, string>>,
scope: Readonly<Record<string, unknown>>,
): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [key, template] of Object.entries(input)) {
const exact = /^\{\{\s*([\w.]+)\s*\}\}$/.exec(template);
if (exact !== null) {
out[key] = resolvePath(scope, exact[1]!);
continue;
}
out[key] = template.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_, path: string) => {
const value = resolvePath(scope, path);
return value === null || value === undefined ? '' : String(value);
});
}
return out;
}
function resolvePath(scope: Readonly<Record<string, unknown>>, path: string): unknown {
let current: unknown = scope;
for (const segment of path.split('.')) {
if (current === null || current === undefined || typeof current !== 'object') return undefined;
current = (current as Record<string, unknown>)[segment];
}
return current;
}
@@ -0,0 +1,90 @@
/**
* skill-engine 用例 —— 编排 domain,依赖 port.ts(编码规范 §5.3)。
*/
import type { Skill } from '@contracts';
import type { RunScriptInput, ScriptOutcome, SkillEngineService } from '../../contract.ts';
import { SkillPowerEscalationError } from '../../contract.ts';
import type { Clock, MemoryRecorder, ScriptToolInvoker, SkillCatalog } from '../../port.ts';
import { checkSkillPower, crystallizedChain, interpolate, skillIndex, stampOf } from '../domain/skill-assembly.ts';
export type SkillEngineDeps = {
readonly catalog: SkillCatalog;
readonly tools: ScriptToolInvoker;
readonly memory: MemoryRecorder;
readonly clock: Clock;
};
export function createSkillEngineService(deps: SkillEngineDeps): SkillEngineService {
async function assemble(activityId: string, skillIds: readonly string[]): Promise<Skill[]> {
const [all, allowlist] = await Promise.all([
deps.catalog.skillsOf(activityId),
deps.catalog.allowlistOf(activityId),
]);
const picked = all.filter((s) => skillIds.includes(s.id));
// 载方法不载权力:装配前先查扩权
const violations = picked.flatMap((s) => checkSkillPower(s, allowlist));
if (violations.length > 0) throw new SkillPowerEscalationError(violations);
return picked;
}
return {
async index(activityId) {
return skillIndex(await deps.catalog.skillsOf(activityId));
},
async load(activityId, skillIds) {
return assemble(activityId, skillIds);
},
async stamp(activityId, skillIds) {
return stampOf(await assemble(activityId, skillIds));
},
/** 结晶片段脚本照谱执行 —— 每步经 ToolInvoker,留痕不可绕过 */
async runScript(input: RunScriptInput): Promise<ScriptOutcome> {
const [skill] = await assemble(input.activityId, [input.skillId]);
if (skill === undefined) {
return { ok: false, steps: [], stamp: [] };
}
const chain = crystallizedChain(skill);
const steps: Array<{ tool: string; ok: boolean; output?: Record<string, unknown>; error?: string }> = [];
let ok = true;
for (const [index, step] of chain.entries()) {
const payload = interpolate(step.input, input.scope);
await deps.memory.append({
taskId: input.taskId,
kind: 'action',
title: `技能 ${skill.label} · 结晶片段 ${index + 1}:${step.tool}`,
detail: { skillId: skill.id, version: skill.version, input: payload },
});
const result = await deps.tools.invoke({
tool: step.tool,
input: payload,
activityId: input.activityId,
taskId: input.taskId,
...(input.stepNo === undefined ? {} : { stepNo: input.stepNo * 100 + index }),
});
steps.push({
tool: step.tool,
ok: result.ok,
...(result.output === undefined ? {} : { output: result.output }),
...(result.error === undefined ? {} : { error: result.error.message }),
});
await deps.memory.append({
taskId: input.taskId,
kind: 'result',
title: `${step.tool} ${result.ok ? '成功' : '失败'}`,
detail: { output: result.output, error: result.error?.message },
});
if (!result.ok) {
ok = false;
break;
}
}
return { ok, steps, stamp: stampOf([skill]) };
},
};
}
@@ -0,0 +1,7 @@
{
"name": "@modules/skill-engine",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": { ".": "./index.ts" }
}
+30
View File
@@ -0,0 +1,30 @@
/**
* module: skill-engine —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3)。
*/
import type { Skill, TaskId } from '@contracts';
export type Clock = { now(): Date };
/** 技能目录 —— app 注入 `activity-registry`(L4 同层):技能是活动的资产之一 */
export type SkillCatalog = {
skillsOf(activityId: string): Promise<Skill[]>;
allowlistOf(activityId: string): Promise<string[]>;
};
/**
* 工具调用 —— app 注入 `toolbox`(L2)的 `ToolInvoker`。
* 脚本引用的工具**仍过本活动白名单与调用契约** —— 载方法不载权力。
*/
export type ScriptToolInvoker = {
invoke(input: {
tool: string;
input: Record<string, unknown>;
activityId: string;
taskId: TaskId;
stepNo?: number;
}): Promise<{ ok: boolean; output?: Record<string, unknown>; error?: { message: string } }>;
};
export type MemoryRecorder = {
append(input: { taskId: TaskId; kind: 'action' | 'result'; title: string; detail?: Record<string, unknown> }): Promise<void>;
};
+37
View File
@@ -0,0 +1,37 @@
# module: task
职责: 任务运行时:生命周期与**终态五名裁定**、Harness 预算执行器 / 停滞熔断、
控制三语义、改派、断点续跑的就绪判定、封卷与蒸馏钩子的触发
不负责: 范式的推进逻辑(见 loop-engine / flow-engine,经 `ParadigmEngine` 端口)、
放行与落地(见 release)、工作流的编排与聚合(见 process)
公开出口: index.ts
- 类型:`TaskService` `Task` `StartTaskInput` `TickOutcome` `Consumption` `HarnessVerdict`
`ControlOutcome` `TerminalCause` `ResumeReadiness`
- 错误:`TaskNotFoundError` `TaskAlreadyTerminalError`
- 端口类型:`TaskStore` `ActivityLoader` `SchemaValidator` `ParadigmEngine` `MemoryRecorder`
`ListenerProbe` `PersistenceProbe` `TaskEventPublisher` `ReassignNotifier`
`InteractionPhaseSetter`
- 用例工厂:`createTaskService(deps): TaskService`
- 纯函数:`judge` `budgetBreaches` `isStalled` `terminalOf` `durationMs`
`applyControl` `decideTerminal` `isSuccess` `triggersFailureDisposition`
`resumeReadiness` `isPassiveOpen` `landingBlocked` `rejectionKeepsRunning`
- adapter:`createTaskStore`;Drizzle 表:`taskTable`
层级: L6(编码规范 §4)
依赖: @contracts;@shared/db;loop-engine / flow-engine / activity-registry(L5/L4);
port 注入:memory / event / message / interaction / ledger
被依赖: process, org(停用前置检查的在途任务), report, app
不变量(OS 04 / 4.3,AM 10 / 12.1):
- **恰一负责人**:任务必绑定恰一个 `ownerId`;改派**留痕并通知双方**
- **输入必过 schema**:`data` 缺项**不得启动**;`message` 计入初始观察但不得扩大授权
- **预算环外强制**:每一拍先 `judge` 再交引擎 —— 活动代码无从自查自守;
任一项触顶即 `exhausted`,空转即 `stalled`
- **监听中是健康态**:有监听在等或等人回传一律**不算停滞**
- **终态五名封闭**:`decideTerminal` 是唯一命名口;`released` 是唯一成功终态
- **已终结不得再推进**:`tick` / `deliver` 对终态任务抛 `TaskAlreadyTerminalError` ——
流级重做一律重开新实例,不复活旧任务
- **叫停不是终态**:转挂起待纠偏,并记**最重档纠正信号**
- **封卷触发蒸馏**:终态后 `seal` + `distill`,产出活动记忆候选条目
- **断点续跑靠三份持久状态**:任务记忆 / 草稿层 / 运行时实例状态,缺一即不可续
@@ -0,0 +1,158 @@
/** Harness 与终态五名裁定 —— domain 测试零 mock(OS 04 / 4.3,AM 10) */
import { describe, expect, it } from 'vitest';
import type { BudgetCaps } from '@contracts';
import { budgetBreaches, durationMs, isStalled, judge, terminalOf, ZERO_CONSUMPTION } from '../internal/domain/harness.ts';
import {
applyControl,
decideTerminal,
isPassiveOpen,
isSuccess,
isTerminalControl,
landingBlocked,
rejectionKeepsRunning,
resumeReadiness,
triggersFailureDisposition,
} from '../internal/domain/lifecycle.ts';
const caps: BudgetCaps = { steps: 50, tokens: 100_000, activeMinutes: 120, humanCalls: 5 };
describe('预算执行器 —— 环外强制,活动不自查自守', () => {
it('未越限即无 breach', () => {
expect(budgetBreaches({ steps: 10, tokens: 100, activeMs: 1000, humanCalls: 1 }, caps)).toEqual([]);
});
it('任一项触顶即熔断', () => {
expect(budgetBreaches({ ...ZERO_CONSUMPTION, steps: 50 }, caps).map((b) => b.item)).toEqual(['steps']);
expect(budgetBreaches({ ...ZERO_CONSUMPTION, tokens: 100_000 }, caps).map((b) => b.item)).toEqual(['tokens']);
expect(budgetBreaches({ ...ZERO_CONSUMPTION, humanCalls: 5 }, caps).map((b) => b.item)).toEqual(['humanCalls']);
});
it('活跃时长按分钟折算', () => {
expect(budgetBreaches({ ...ZERO_CONSUMPTION, activeMs: 120 * 60_000 }, caps).map((b) => b.item)).toEqual(['activeMs']);
expect(budgetBreaches({ ...ZERO_CONSUMPTION, activeMs: 119 * 60_000 }, caps)).toEqual([]);
});
it('多项同时越限全部报出', () => {
expect(budgetBreaches({ steps: 99, tokens: 999_999, activeMs: 0, humanCalls: 0 }, caps)).toHaveLength(2);
});
});
describe('停滞检测 —— 监听中是健康态', () => {
const base = { sinceLastProgressMs: 60 * 60_000, stallAfterMs: 30 * 60_000, hasArmedListener: false, awaitingHumanReply: false };
it('该动未动、超限仍无推进即停滞', () => {
expect(isStalled(base)).toBe(true);
});
it('有监听在等一律不算停滞 —— 与停滞分别对待', () => {
expect(isStalled({ ...base, hasArmedListener: true })).toBe(false);
});
it('等人回传也不算停滞', () => {
expect(isStalled({ ...base, awaitingHumanReply: true })).toBe(false);
});
it('未到阈值不算停滞', () => {
expect(isStalled({ ...base, sinceLastProgressMs: 10 * 60_000 })).toBe(false);
});
});
describe('judge —— 预算优先于停滞', () => {
const stall = { sinceLastProgressMs: 999_999_999, stallAfterMs: 1000, hasArmedListener: false, awaitingHumanReply: false };
it('越限即 exhausted', () => {
const verdict = judge({ ...ZERO_CONSUMPTION, steps: 50 }, caps, stall);
expect(verdict.kind).toBe('exhausted');
expect(terminalOf(verdict)).toBe('exhausted');
});
it('未越限但空转即 stalled', () => {
const verdict = judge(ZERO_CONSUMPTION, caps, stall);
expect(verdict.kind).toBe('stalled');
expect(terminalOf(verdict)).toBe('stalled');
});
it('都没事即 ok,不产生终态', () => {
const verdict = judge(ZERO_CONSUMPTION, caps, { ...stall, hasArmedListener: true });
expect(verdict).toEqual({ kind: 'ok' });
expect(terminalOf(verdict)).toBeUndefined();
});
});
describe('控制三语义', () => {
it('暂停 = 可恢复冻结', () => {
expect(applyControl('pause')).toEqual({ kind: 'suspended', resumable: true });
});
it('叫停 = 挂起 + 最重档纠正信号,恢复归负责人', () => {
expect(applyControl('halt')).toEqual({ kind: 'halted', resumable: true, correctionSignal: 'heaviest' });
});
it('终止 = 不可恢复关闭', () => {
expect(applyControl('terminate')).toEqual({ kind: 'terminated', resumable: false, terminal: 'terminated' });
});
it('只有终止是终态 —— 叫停不是', () => {
expect(isTerminalControl('halt')).toBe(false);
expect(isTerminalControl('pause')).toBe(false);
expect(isTerminalControl('terminate')).toBe(true);
});
});
describe('终态五名 —— 系统命名,封闭枚举', () => {
it('放行完成是唯一成功终态', () => {
expect(decideTerminal({ kind: 'released' }).terminal).toBe('released');
expect(isSuccess('released')).toBe(true);
for (const t of ['blocked', 'stalled', 'exhausted', 'terminated'] as const) expect(isSuccess(t)).toBe(false);
});
it('五名各有其因', () => {
expect(decideTerminal({ kind: 'blocked', reason: '监听时限走完' }).terminal).toBe('blocked');
expect(decideTerminal({ kind: 'stalled', evidence: '空转' }).terminal).toBe('stalled');
expect(decideTerminal({ kind: 'exhausted', detail: 'token' }).terminal).toBe('exhausted');
expect(decideTerminal({ kind: 'terminated', reason: '治理台终止' }).terminal).toBe('terminated');
});
it('非完成终态触发流程层失败走向', () => {
expect(triggersFailureDisposition('released')).toBe(false);
expect(triggersFailureDisposition('blocked')).toBe(true);
});
});
describe('「不是终态」的三样', () => {
it('驳回是环内回转,任务继续', () => {
expect(rejectionKeepsRunning()).toEqual({ runtime: 'running' });
});
it('落地受阻是运行态', () => {
expect(landingBlocked('连接超时')).toEqual({ runtime: 'landing-blocked', reason: '连接超时' });
});
it('打开任务只是打开呈现', () => {
expect(isPassiveOpen('running')).toBe(false);
expect(isPassiveOpen('awaiting-event')).toBe(true);
});
});
describe('断点续跑 —— 三份持久状态共同保证', () => {
it('三样齐备才算可续', () => {
expect(resumeReadiness({ 'task-memory': true, 'draft-layer': true, 'runtime-state': true })).toEqual({
ready: true,
missing: [],
});
});
it('缺一即不可续,并指出缺谁', () => {
expect(resumeReadiness({ 'task-memory': true, 'draft-layer': false, 'runtime-state': true })).toEqual({
ready: false,
missing: ['draft-layer'],
});
});
});
describe('durationMs', () => {
it('解析 ISO-8601', () => {
expect(durationMs('PT30M')).toBe(30 * 60_000);
expect(durationMs('P1D')).toBe(86_400_000);
});
it('非法即报错', () => {
expect(() => durationMs('30分钟')).toThrow(/非法时长/);
});
});
+104
View File
@@ -0,0 +1,104 @@
/**
* module: task —— 对外类型(编码规范 §10.1;语义出处 OS 04,AM 01/10)。
*
* 任务运行时:生命周期与**终态五名裁定**、Harness 预算执行器 / 停滞熔断、
* 控制三语义、改派、断点续跑。范式(loop / flow)在此汇流 —— 对流程层与交互层不可区分。
*/
import type { BudgetCaps, ControlSemantic, Paradigm, StaffId, TaskId, TaskRuntimeState, TerminalState } from '@contracts';
import type { BudgetBreach, Consumption, HarnessVerdict } from './internal/domain/harness.ts';
import type { ControlOutcome, ResumeReadiness, TaskSnapshot, TerminalCause } from './internal/domain/lifecycle.ts';
export type {
BudgetBreach,
BudgetCaps,
Consumption,
ControlOutcome,
ControlSemantic,
HarnessVerdict,
Paradigm,
ResumeReadiness,
TaskRuntimeState,
TaskSnapshot,
TerminalCause,
TerminalState,
};
export class TaskNotFoundError extends Error {
constructor(readonly taskId: TaskId) {
super(`任务不存在:${taskId}`);
this.name = 'TaskNotFoundError';
}
}
export class TaskAlreadyTerminalError extends Error {
constructor(
readonly taskId: TaskId,
readonly terminal: TerminalState,
) {
super(`任务已终结(${terminal}),不得再推进:${taskId} —— 流级重做一律重开新实例`);
this.name = 'TaskAlreadyTerminalError';
}
}
/** 一单任务 —— 活动的运行时实例;**恰有一名负责人** */
export type Task = {
readonly id: TaskId;
readonly activityId: string;
readonly paradigm: Paradigm;
/** 起单来源:工作流节点;登记触发的直起单为 undefined */
readonly workflowId?: string;
readonly nodeId?: string;
readonly ownerId: StaffId;
readonly title: string;
readonly runtime: TaskRuntimeState;
readonly terminal?: TerminalState;
readonly reason?: string;
/** 装载信封:包版本 + 各资产生效版 —— 行为可归因 */
readonly envelope: Readonly<Record<string, unknown>>;
readonly caps: BudgetCaps;
readonly used: Consumption;
readonly startedAt: string;
readonly finishedAt?: string;
};
export type StartTaskInput = {
readonly activityId: string;
readonly ownerId: StaffId;
readonly title: string;
readonly workflowId?: string;
readonly nodeId?: string;
/** 输入:data(按 schema 校验,缺项不得启动)+ message(委托消息,可为空) */
readonly data: Record<string, unknown>;
readonly message?: string;
};
export type TickOutcome = {
readonly task: Task;
readonly verdict: HarnessVerdict;
readonly progressed: boolean;
};
export type TaskService = {
/** 起单:装载 → 校验输入 → 建任务记忆 → 首个回合 */
start(input: StartTaskInput): Promise<Task>;
get(taskId: TaskId): Promise<Task>;
listByOwner(ownerId: StaffId, openOnly?: boolean): Promise<Task[]>;
/** org 停用前置检查要问的「在途任务」 */
openTasksOf(ownerId: StaffId): Promise<Array<{ taskId: string; title: string }>>;
/** 推进一拍:**预算在环外先扣先判**,再交范式引擎 */
tick(taskId: TaskId): Promise<TickOutcome>;
/** 事件驱动续跑 —— 挂起期间事件不丢,恢复即补投 */
deliver(taskId: TaskId, event: { kind: 'data' | 'time' | 'human' | 'control'; ref: string; payload: Record<string, unknown> }): Promise<TickOutcome>;
/** 控制三语义:暂停 / 叫停 / 终止 */
control(taskId: TaskId, semantic: ControlSemantic, by: StaffId, reason?: string): Promise<ControlOutcome>;
resume(taskId: TaskId, by: StaffId): Promise<Task>;
/** 改派:在途任务的负责人变更,**留痕并通知双方** */
reassign(taskId: TaskId, to: StaffId, by: StaffId): Promise<Task>;
/** 终态裁定 —— 完成由框架裁定,终态由系统命名 */
finish(taskId: TaskId, cause: TerminalCause): Promise<Task>;
/** 断点续跑的三份持久状态是否齐备 */
resumeReadiness(taskId: TaskId): Promise<ResumeReadiness>;
};
+64
View File
@@ -0,0 +1,64 @@
/**
* module: task —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。
*/
export type {
BudgetBreach,
BudgetCaps,
Consumption,
ControlOutcome,
ControlSemantic,
HarnessVerdict,
Paradigm,
ResumeReadiness,
StartTaskInput,
Task,
TaskRuntimeState,
TaskService,
TaskSnapshot,
TerminalCause,
TerminalState,
TickOutcome,
} from './contract.ts';
export { TaskAlreadyTerminalError, TaskNotFoundError } from './contract.ts';
export type {
ActivityLoader,
Clock,
IdGenerator,
InteractionPhaseSetter,
ListenerProbe,
MemoryRecorder,
ParadigmEngine,
PersistenceProbe,
ReassignNotifier,
SchemaValidator,
TaskEventPublisher,
TaskStore,
} from './port.ts';
export type { TaskServiceDeps } from './internal/usecase/task-service.ts';
export { createTaskService } from './internal/usecase/task-service.ts';
export {
budgetBreaches,
durationMs,
isStalled,
judge,
terminalOf,
ZERO_CONSUMPTION,
} from './internal/domain/harness.ts';
export {
applyControl,
decideTerminal,
isPassiveOpen,
isSuccess,
isTerminalControl,
landingBlocked,
RESUME_SOURCES,
rejectionKeepsRunning,
resumeReadiness,
triggersFailureDisposition,
} from './internal/domain/lifecycle.ts';
export { createTaskStore } from './internal/adapter/task-store.ts';
export { taskTable } from './internal/adapter/task-schema.ts';
@@ -0,0 +1,38 @@
/**
* task 的 sys schema 表(编码规范 §7 / ADR-002)。
*/
import { index, integer, jsonb, text, timestamp } from 'drizzle-orm/pg-core';
import { sys } from '@shared/db.ts';
export const taskTable = sys.table(
'task',
{
id: text('id').primaryKey(),
activityId: text('activity_id').notNull(),
paradigm: text('paradigm').notNull(),
workflowId: text('workflow_id'),
nodeId: text('node_id'),
/** 恰有一名负责人 */
ownerId: text('owner_id').notNull(),
title: text('title').notNull(),
runtime: text('runtime').notNull().default('running'),
/** 终态五名 —— 封闭枚举 */
terminal: text('terminal'),
reason: text('reason'),
/** 装载信封:包版本 + 各资产生效版 —— 行为可归因 */
envelope: jsonb('envelope').notNull(),
caps: jsonb('caps').notNull(),
usedSteps: integer('used_steps').notNull().default(0),
usedTokens: integer('used_tokens').notNull().default(0),
usedActiveMs: integer('used_active_ms').notNull().default(0),
usedHumanCalls: integer('used_human_calls').notNull().default(0),
lastProgressAt: timestamp('last_progress_at', { withTimezone: true }),
startedAt: timestamp('started_at', { withTimezone: true }).notNull().defaultNow(),
finishedAt: timestamp('finished_at', { withTimezone: true }),
},
(t) => [
index('task_owner_idx').on(t.ownerId, t.runtime),
index('task_workflow_idx').on(t.workflowId),
index('task_terminal_idx').on(t.terminal),
],
);
@@ -0,0 +1,124 @@
/**
* task 存储 adapter —— Drizzle 只准出现在 adapter(编码规范 §5.3)。
*/
import { and, asc, eq, isNull, sql } from 'drizzle-orm';
import type { BudgetCaps, Paradigm, StaffId, TaskId, TaskRuntimeState, TerminalState } from '@contracts';
import type { Db } from '@shared/db.ts';
import type { TaskStore } from '../../port.ts';
import type { Task } from '../../contract.ts';
import type { Consumption } from '../domain/harness.ts';
import { taskTable } from './task-schema.ts';
type Row = typeof taskTable.$inferSelect;
function toTask(row: Row): Task {
return {
id: row.id as TaskId,
activityId: row.activityId,
paradigm: row.paradigm as Paradigm,
...(row.workflowId === null ? {} : { workflowId: row.workflowId }),
...(row.nodeId === null ? {} : { nodeId: row.nodeId }),
ownerId: row.ownerId as StaffId,
title: row.title,
runtime: row.runtime as TaskRuntimeState,
...(row.terminal === null ? {} : { terminal: row.terminal as TerminalState }),
...(row.reason === null ? {} : { reason: row.reason }),
envelope: row.envelope as Record<string, unknown>,
caps: row.caps as BudgetCaps,
used: {
steps: row.usedSteps,
tokens: row.usedTokens,
activeMs: row.usedActiveMs,
humanCalls: row.usedHumanCalls,
},
startedAt: row.startedAt.toISOString(),
...(row.finishedAt === null ? {} : { finishedAt: row.finishedAt.toISOString() }),
};
}
export function createTaskStore(db: Db): TaskStore {
return {
async insert(task) {
await db.insert(taskTable).values({
id: task.id,
activityId: task.activityId,
paradigm: task.paradigm,
workflowId: task.workflowId ?? null,
nodeId: task.nodeId ?? null,
ownerId: task.ownerId,
title: task.title,
runtime: task.runtime,
envelope: task.envelope,
caps: task.caps,
startedAt: new Date(task.startedAt),
});
},
async get(taskId: TaskId) {
const rows = await db.select().from(taskTable).where(eq(taskTable.id, taskId)).limit(1);
return rows[0] === undefined ? undefined : toTask(rows[0]);
},
async listByOwner(ownerId: StaffId, openOnly) {
const conditions = [eq(taskTable.ownerId, ownerId)];
if (openOnly) conditions.push(isNull(taskTable.terminal));
const rows = await db
.select()
.from(taskTable)
.where(and(...conditions))
.orderBy(asc(taskTable.startedAt));
return rows.map(toTask);
},
async update(taskId: TaskId, patch) {
const values: Record<string, unknown> = {};
if (patch.runtime !== undefined) values['runtime'] = patch.runtime;
if (patch.terminal !== undefined) values['terminal'] = patch.terminal;
if (patch.reason !== undefined) values['reason'] = patch.reason;
if (patch.ownerId !== undefined) values['ownerId'] = patch.ownerId;
if (patch.finishedAt !== undefined) values['finishedAt'] = new Date(patch.finishedAt);
if (patch.used !== undefined) {
values['usedSteps'] = patch.used.steps;
values['usedTokens'] = patch.used.tokens;
values['usedActiveMs'] = patch.used.activeMs;
values['usedHumanCalls'] = patch.used.humanCalls;
}
const [row] = await db.update(taskTable).set(values).where(eq(taskTable.id, taskId)).returning();
if (row === undefined) throw new Error(`任务不存在:${taskId}`);
return toTask(row);
},
async addConsumption(taskId: TaskId, delta): Promise<Consumption> {
const [row] = await db
.update(taskTable)
.set({
usedSteps: sql`${taskTable.usedSteps} + ${delta.steps ?? 0}`,
usedTokens: sql`${taskTable.usedTokens} + ${delta.tokens ?? 0}`,
usedActiveMs: sql`${taskTable.usedActiveMs} + ${delta.activeMs ?? 0}`,
usedHumanCalls: sql`${taskTable.usedHumanCalls} + ${delta.humanCalls ?? 0}`,
})
.where(eq(taskTable.id, taskId))
.returning();
if (row === undefined) throw new Error(`任务不存在:${taskId}`);
return {
steps: row.usedSteps,
tokens: row.usedTokens,
activeMs: row.usedActiveMs,
humanCalls: row.usedHumanCalls,
};
},
async lastProgressAt(taskId: TaskId) {
const rows = await db
.select({ at: taskTable.lastProgressAt })
.from(taskTable)
.where(eq(taskTable.id, taskId))
.limit(1);
return rows[0]?.at ?? undefined;
},
async markProgress(taskId: TaskId, at: Date) {
await db.update(taskTable).set({ lastProgressAt: at }).where(eq(taskTable.id, taskId));
},
};
}
@@ -0,0 +1,80 @@
/**
* Harness:预算执行器与停滞检测 —— 纯判定,零 IO(编码规范 §5.3;语义出处 OS 04,AM 10.1)。
*
* 「预算与熔断由活动基座**在环外强制执行** —— 活动仅在 Harness 配置中申报参数,
* **不自查自守**。任一项越限,基座抛控制事件并挂起任务。」
*/
import type { BudgetCaps, TerminalState } from '@contracts';
/** 计量口径(OS 04):步 / token / 活跃时长 / 调人次数 */
export type Consumption = {
/** loop 的一次决策循环 / flow 的一个声明步 */
readonly steps: number;
/** LLM 调用出入 token 累计 */
readonly tokens: number;
/** **活跃时长**:Agent 机制运行与工具执行的时间;挂起、监听等待、待卡回传**不计入** */
readonly activeMs: number;
/** 调人次数按卡计 */
readonly humanCalls: number;
};
export const ZERO_CONSUMPTION: Consumption = { steps: 0, tokens: 0, activeMs: 0, humanCalls: 0 };
export type BudgetBreach = { readonly item: keyof Consumption; readonly used: number; readonly cap: number };
/** 任一项越限即熔断 —— 预算耗尽 */
export function budgetBreaches(used: Consumption, caps: BudgetCaps): BudgetBreach[] {
const out: BudgetBreach[] = [];
if (used.steps >= caps.steps) out.push({ item: 'steps', used: used.steps, cap: caps.steps });
if (used.tokens >= caps.tokens) out.push({ item: 'tokens', used: used.tokens, cap: caps.tokens });
if (used.activeMs >= caps.activeMinutes * 60_000) {
out.push({ item: 'activeMs', used: used.activeMs, cap: caps.activeMinutes * 60_000 });
}
if (used.humanCalls >= caps.humanCalls) out.push({ item: 'humanCalls', used: used.humanCalls, cap: caps.humanCalls });
return out;
}
/**
* 停滞检测:loop = Enabled 集与任务数据皆无变化、且**无监听在等**的空转;
* flow = 当前步长期无进展且无监听在等。
* 「**监听中是健康态**,与停滞分别对待」—— 有监听在等一律不算停滞。
*/
export type StallInput = {
readonly sinceLastProgressMs: number;
readonly stallAfterMs: number;
readonly hasArmedListener: boolean;
readonly awaitingHumanReply: boolean;
};
export function isStalled(input: StallInput): boolean {
if (input.hasArmedListener || input.awaitingHumanReply) return false;
return input.sinceLastProgressMs >= input.stallAfterMs;
}
/** Harness 的裁定:什么都不做 / 熔断 / 停滞 */
export type HarnessVerdict =
| { readonly kind: 'ok' }
| { readonly kind: 'exhausted'; readonly breaches: readonly BudgetBreach[] }
| { readonly kind: 'stalled' };
export function judge(used: Consumption, caps: BudgetCaps, stall: StallInput): HarnessVerdict {
const breaches = budgetBreaches(used, caps);
if (breaches.length > 0) return { kind: 'exhausted', breaches };
if (isStalled(stall)) return { kind: 'stalled' };
return { kind: 'ok' };
}
/** 「错误与耗尽不得计为成功」—— Harness 裁定直接映射终态 */
export function terminalOf(verdict: HarnessVerdict): TerminalState | undefined {
if (verdict.kind === 'exhausted') return 'exhausted';
if (verdict.kind === 'stalled') return 'stalled';
return undefined;
}
/** ISO-8601 duration → 毫秒(与 event 的口径同一子集) */
export function durationMs(iso: string): number {
const m = /^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/.exec(iso);
if (m === null) throw new Error(`非法时长:${iso}`);
const [, d, h, min, s] = m;
return Number(d ?? 0) * 86_400_000 + Number(h ?? 0) * 3_600_000 + Number(min ?? 0) * 60_000 + Number(s ?? 0) * 1_000;
}
@@ -0,0 +1,112 @@
/**
* 任务生命周期与终态五名裁定 —— 纯逻辑,零 IO(编码规范 §5.3;语义出处 OS 4.3,AM 01/10)。
*
* 「**完成由框架裁定,终态由系统命名**」—— 终态是封闭枚举,活动不得增删。
*/
import type { ControlSemantic, TaskRuntimeState, TerminalState } from '@contracts';
export type TaskSnapshot = {
readonly runtime: TaskRuntimeState;
readonly terminal?: TerminalState;
readonly reason?: string;
};
export const RUNNING: TaskSnapshot = { runtime: 'running' };
/* ── 控制三语义(OS 06) ────────────────────────────────────────────── */
export type ControlOutcome =
/** 暂停 = 可恢复冻结(监听与时限停摆) */
| { readonly kind: 'suspended'; readonly resumable: true }
/** 叫停 = 负责人紧急中断:挂起并**记最重档纠正**,恢复归负责人 */
| { readonly kind: 'halted'; readonly resumable: true; readonly correctionSignal: 'heaviest' }
/** 终止 = 不可恢复关闭 */
| { readonly kind: 'terminated'; readonly resumable: false; readonly terminal: TerminalState };
export function applyControl(semantic: ControlSemantic): ControlOutcome {
switch (semantic) {
case 'pause':
return { kind: 'suspended', resumable: true };
case 'halt':
return { kind: 'halted', resumable: true, correctionSignal: 'heaviest' };
case 'terminate':
return { kind: 'terminated', resumable: false, terminal: 'terminated' };
}
}
/** 叫停**不是终态** —— 转挂起待纠偏,处置后恢复,或由负责人终止 */
export function isTerminalControl(semantic: ControlSemantic): boolean {
return semantic === 'terminate';
}
/* ── 终态裁定 ───────────────────────────────────────────────────────── */
export type TerminalCause =
/** 放行通过且落地收敛,输出口已发射 —— **唯一成功终态** */
| { readonly kind: 'released' }
/** 无法推进,封卷待治理处置 */
| { readonly kind: 'blocked'; readonly reason: string }
| { readonly kind: 'stalled'; readonly evidence: string }
| { readonly kind: 'exhausted'; readonly detail: string }
| { readonly kind: 'terminated'; readonly reason: string };
export function decideTerminal(cause: TerminalCause): { terminal: TerminalState; reason: string } {
switch (cause.kind) {
case 'released':
return { terminal: 'released', reason: '放行通过且落地收敛,输出口已发射' };
case 'blocked':
return { terminal: 'blocked', reason: cause.reason };
case 'stalled':
return { terminal: 'stalled', reason: cause.evidence };
case 'exhausted':
return { terminal: 'exhausted', reason: cause.detail };
case 'terminated':
return { terminal: 'terminated', reason: cause.reason };
}
}
export function isSuccess(terminal: TerminalState): boolean {
return terminal === 'released';
}
/** 非完成终态触发流程层的**失败走向**(OS 03) */
export function triggersFailureDisposition(terminal: TerminalState): boolean {
return terminal !== 'released';
}
/* ── 「不是终态」的三样(OS 4.3) ──────────────────────────────────── */
/** 驳回是**环内回转**,任务继续 —— 不出活动 */
export function rejectionKeepsRunning(): TaskSnapshot {
return { runtime: 'running' };
}
/** 落地受阻是**运行态**,处置后仍收敛于终态名录 */
export function landingBlocked(reason: string): TaskSnapshot {
return { runtime: 'landing-blocked', reason };
}
/* ── 断点续跑:三份持久状态(OS 09) ──────────────────────────────── */
export const RESUME_SOURCES = ['task-memory', 'draft-layer', 'runtime-state'] as const;
export type ResumeSource = (typeof RESUME_SOURCES)[number];
export type ResumeReadiness = {
readonly ready: boolean;
readonly missing: readonly ResumeSource[];
};
/**
* 「任务做一半的续跑由三份持久状态**共同**保证:
* 任务记忆(按窗口重建上下文)、草稿层(截至断点的过程产物与动作意向)、
* 运行时实例状态(loop 的模型定位与就绪集 / flow 的步序位置)。」
*/
export function resumeReadiness(present: Readonly<Record<ResumeSource, boolean>>): ResumeReadiness {
const missing = RESUME_SOURCES.filter((s) => !present[s]);
return { ready: missing.length === 0, missing };
}
/** 「打开任务只是打开呈现,不是启动过程」—— 过程要么在跑,要么挂起在等 */
export function isPassiveOpen(state: TaskRuntimeState): boolean {
return state !== 'running';
}
@@ -0,0 +1,254 @@
/**
* task 用例 —— 任务运行时(编码规范 §5.3;语义出处 OS 04 / 4.3,AM 10)。
*
* 预算在**环外**强制:每一拍先扣先判,再交范式引擎 —— 活动不自查自守。
*/
import type { StaffId, TaskId } from '@contracts';
import { taskIdSchema } from '@contracts';
import type { StartTaskInput, Task, TaskService, TickOutcome } from '../../contract.ts';
import { TaskAlreadyTerminalError, TaskNotFoundError } from '../../contract.ts';
import type {
ActivityLoader,
Clock,
IdGenerator,
InteractionPhaseSetter,
ListenerProbe,
MemoryRecorder,
ParadigmEngine,
PersistenceProbe,
ReassignNotifier,
SchemaValidator,
TaskEventPublisher,
TaskStore,
} from '../../port.ts';
import { durationMs, judge, terminalOf, ZERO_CONSUMPTION } from '../domain/harness.ts';
import { applyControl, decideTerminal, resumeReadiness } from '../domain/lifecycle.ts';
export type TaskServiceDeps = {
readonly store: TaskStore;
readonly activities: ActivityLoader;
readonly schemas: SchemaValidator;
/** 范式引擎按 paradigm 分派 —— 对上层不可区分 */
readonly engines: Readonly<Record<'loop' | 'flow', ParadigmEngine>>;
readonly memory: MemoryRecorder;
readonly listeners: ListenerProbe;
readonly persistence: PersistenceProbe;
readonly events: TaskEventPublisher;
readonly notifier: ReassignNotifier;
readonly interaction: InteractionPhaseSetter;
readonly clock: Clock;
readonly ids: IdGenerator;
};
export function createTaskService(deps: TaskServiceDeps): TaskService {
async function require(taskId: TaskId): Promise<Task> {
const task = await deps.store.get(taskId);
if (task === undefined) throw new TaskNotFoundError(taskId);
return task;
}
function engineOf(task: Task): ParadigmEngine {
return deps.engines[task.paradigm];
}
/** Harness 在环外先判:越限即熔断并挂起,处置归治理台 */
async function harnessCheck(task: Task, stallAfter: string) {
const lastProgress = (await deps.store.lastProgressAt(task.id)) ?? new Date(task.startedAt);
const stall = {
sinceLastProgressMs: deps.clock.now().getTime() - lastProgress.getTime(),
stallAfterMs: durationMs(stallAfter),
hasArmedListener: await deps.listeners.hasArmedListener(task.id),
awaitingHumanReply: task.runtime === 'awaiting-human',
};
return judge(task.used, task.caps, stall);
}
return {
async start(input: StartTaskInput): Promise<Task> {
const loaded = await deps.activities.load(input.activityId);
// 输入 data 必过 schema 校验,**缺项不得启动**(AM 12.1)
const check = deps.schemas.validate(loaded.inputSchema, input.data);
if (!check.ok) throw new Error(`输入不合契约,不得启动:${check.errors.join('; ')}`);
const now = deps.clock.now();
const task: Task = {
id: taskIdSchema.parse(deps.ids.next()),
activityId: input.activityId,
paradigm: loaded.paradigm,
...(input.workflowId === undefined ? {} : { workflowId: input.workflowId }),
...(input.nodeId === undefined ? {} : { nodeId: input.nodeId }),
ownerId: input.ownerId,
title: input.title,
runtime: 'running',
envelope: loaded.envelope,
caps: loaded.caps,
used: ZERO_CONSUMPTION,
startedAt: now.toISOString(),
};
await deps.store.insert(task);
await deps.memory.append({
taskId: task.id,
kind: 'engine-transition',
title: `起单:${task.title}`,
// message 计入初始观察、全程留痕;**不得扩大授权边界**
detail: { data: input.data, message: input.message, envelope: loaded.envelope },
});
await engineOf(task).load(task.id, loaded.businessModel);
await deps.interaction.setPhase(task.id, 'active');
await deps.store.markProgress(task.id, now);
return task;
},
async get(taskId) {
return require(taskId);
},
async listByOwner(ownerId, openOnly = false) {
return deps.store.listByOwner(ownerId, openOnly);
},
async openTasksOf(ownerId) {
const tasks = await deps.store.listByOwner(ownerId, true);
return tasks.map((t) => ({ taskId: t.id, title: t.title }));
},
async tick(taskId): Promise<TickOutcome> {
const task = await require(taskId);
if (task.terminal !== undefined) throw new TaskAlreadyTerminalError(taskId, task.terminal);
const loaded = await deps.activities.load(task.activityId);
const verdict = await harnessCheck(task, loaded.stallAfter);
const terminal = terminalOf(verdict);
if (terminal !== undefined) {
const finished = await this.finish(
taskId,
terminal === 'exhausted'
? { kind: 'exhausted', detail: JSON.stringify(verdict) }
: { kind: 'stalled', evidence: '该动未动、超限仍无推进' },
);
return { task: finished, verdict, progressed: false };
}
const outcome = await engineOf(task).advance(taskId);
// 步计量:loop 的一次决策循环 / flow 的一个声明步
const used = await deps.store.addConsumption(taskId, { steps: 1 });
await deps.store.markProgress(taskId, deps.clock.now());
const runtime =
outcome.status === 'awaiting'
? 'awaiting-event'
: outcome.status === 'blocked'
? 'suspended'
: 'running';
const updated = await deps.store.update(taskId, { runtime, used });
await deps.interaction.setPhase(taskId, runtime === 'running' ? 'active' : 'awaiting-event');
if (outcome.status === 'blocked') {
const finished = await this.finish(taskId, { kind: 'blocked', reason: outcome.reason ?? '无法推进' });
return { task: finished, verdict, progressed: true };
}
return { task: updated, verdict, progressed: true };
},
async deliver(taskId, event): Promise<TickOutcome> {
const task = await require(taskId);
if (task.terminal !== undefined) throw new TaskAlreadyTerminalError(taskId, task.terminal);
await deps.memory.append({
taskId,
kind: 'observation',
title: `事件到达:${event.kind}/${event.ref}`,
detail: event.payload,
});
await engineOf(task).onEvent(taskId, event);
await deps.store.markProgress(taskId, deps.clock.now());
return this.tick(taskId);
},
/** 控制三语义 —— 叫停留痕为最重档纠正信号 */
async control(taskId, semantic, by, reason) {
const task = await require(taskId);
const outcome = applyControl(semantic);
await deps.events.publishControl(taskId, semantic, by, reason);
await deps.memory.append({
taskId,
kind: 'engine-transition',
title: `控制事件:${semantic}`,
detail: { by, reason, correctionSignal: outcome.kind === 'halted' ? 'heaviest' : undefined },
});
if (outcome.kind === 'terminated') {
await this.finish(taskId, { kind: 'terminated', reason: reason ?? '治理台终止' });
return outcome;
}
await deps.store.update(taskId, { runtime: 'suspended', ...(reason === undefined ? {} : { reason }) });
await deps.interaction.setPhase(taskId, 'awaiting-event');
void task;
return outcome;
},
async resume(taskId, by) {
const task = await require(taskId);
if (task.terminal !== undefined) throw new TaskAlreadyTerminalError(taskId, task.terminal);
await deps.memory.append({ taskId, kind: 'engine-transition', title: '恢复', detail: { by } });
const updated = await deps.store.update(taskId, { runtime: 'running' });
await deps.interaction.setPhase(taskId, 'active');
return updated;
},
/** 改派:留痕并通知双方(OS 02) */
async reassign(taskId, to, by) {
const task = await require(taskId);
const updated = await deps.store.update(taskId, { ownerId: to });
await deps.memory.append({
taskId,
kind: 'engine-transition',
title: `改派:${task.ownerId} → ${to}`,
detail: { by },
});
await deps.notifier.notify({ from: task.ownerId, to, taskId, title: task.title, by });
return updated;
},
/** 终态由系统命名;封卷触发蒸馏钩子 */
async finish(taskId, cause) {
const task = await require(taskId);
if (task.terminal !== undefined) return task;
const { terminal, reason } = decideTerminal(cause);
const now = deps.clock.now();
const updated = await deps.store.update(taskId, {
runtime: 'terminal',
terminal,
reason,
finishedAt: now.toISOString(),
});
await deps.memory.append({ taskId, kind: 'terminal', title: `终态:${terminal}`, detail: { reason } });
await deps.events.publishTerminal(taskId, terminal, reason);
await deps.interaction.setPhase(taskId, 'terminal');
// 终态封卷 → 归档 → 蒸馏钩子产出活动记忆候选条目
await deps.memory.seal(taskId);
await deps.memory.distill({
taskId,
activityId: task.activityId,
trigger: 'terminal',
prompt: `任务「${task.title}」以 ${terminal} 收场(${reason})。归纳可复用的事实、模式、裁决与例外。`,
});
return updated;
},
async resumeReadiness(taskId) {
const task = await require(taskId);
return resumeReadiness({
'task-memory': await deps.memory.hasHistory(task.id),
'draft-layer': await deps.persistence.hasDrafts(task.id),
'runtime-state': await deps.persistence.hasRuntimeState(task.id),
});
},
};
}
export type { StaffId };
+7
View File
@@ -0,0 +1,7 @@
{
"name": "@modules/task",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": { ".": "./index.ts" }
}
+86
View File
@@ -0,0 +1,86 @@
/**
* module: task —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3)。
*/
import type { BudgetCaps, Paradigm, StaffId, TaskId, TerminalState } from '@contracts';
import type { Consumption } from './internal/domain/harness.ts';
import type { Task } from './contract.ts';
export type Clock = { now(): Date };
export type IdGenerator = { next(): string };
export type TaskStore = {
insert(task: Task): Promise<void>;
get(taskId: TaskId): Promise<Task | undefined>;
listByOwner(ownerId: StaffId, openOnly: boolean): Promise<Task[]>;
update(taskId: TaskId, patch: Partial<Task>): Promise<Task>;
addConsumption(taskId: TaskId, delta: Partial<Consumption>): Promise<Consumption>;
lastProgressAt(taskId: TaskId): Promise<Date | undefined>;
markProgress(taskId: TaskId, at: Date): Promise<void>;
};
/** 活动装载 —— app 注入 `activity-registry`(L4) */
export type ActivityLoader = {
load(activityId: string): Promise<{
paradigm: Paradigm;
caps: BudgetCaps;
envelope: Record<string, unknown>;
/** 输入 data 的 JSON Schema —— 缺项不得启动 */
inputSchema: Record<string, unknown>;
businessModel: unknown;
stallAfter: string;
}>;
};
export type SchemaValidator = {
validate(schema: Record<string, unknown>, value: unknown): { ok: boolean; errors: string[] };
};
/** 范式引擎 —— loop / flow 在此汇流;对上层不可区分(§9 双层边界) */
export type ParadigmEngine = {
load(taskId: TaskId, model: unknown): Promise<void>;
advance(taskId: TaskId): Promise<{ status: 'running' | 'awaiting' | 'submitted' | 'finished' | 'blocked'; reason?: string }>;
onEvent(
taskId: TaskId,
event: { kind: 'data' | 'time' | 'human' | 'control'; ref: string; payload: Record<string, unknown> },
): Promise<{ status: 'running' | 'awaiting' | 'submitted' | 'finished' | 'blocked'; reason?: string }>;
};
export type MemoryRecorder = {
append(input: {
taskId: TaskId;
kind: 'engine-transition' | 'terminal' | 'observation';
title: string;
detail?: Record<string, unknown>;
}): Promise<void>;
seal(taskId: TaskId): Promise<void>;
/** 终态封卷触发蒸馏钩子 —— 产出活动记忆候选条目 */
distill(input: { taskId: TaskId; activityId: string; trigger: 'terminal'; prompt: string }): Promise<unknown>;
hasHistory(taskId: TaskId): Promise<boolean>;
};
/** 监听在等?—— app 注入 `event`(L1):监听中是健康态,与停滞分别对待 */
export type ListenerProbe = {
hasArmedListener(taskId: TaskId): Promise<boolean>;
};
/** 草稿层与运行时状态是否在 —— 断点续跑的三份持久状态 */
export type PersistenceProbe = {
hasDrafts(taskId: TaskId): Promise<boolean>;
hasRuntimeState(taskId: TaskId): Promise<boolean>;
};
/** 控制事件与终态事件 —— app 注入 `event`(L1) */
export type TaskEventPublisher = {
publishControl(taskId: TaskId, semantic: string, by: StaffId, reason?: string): Promise<void>;
publishTerminal(taskId: TaskId, terminal: TerminalState, reason: string): Promise<void>;
};
/** 改派通知 —— app 注入 `message`(L2):改派留痕并**通知双方** */
export type ReassignNotifier = {
notify(input: { from: StaffId; to: StaffId; taskId: TaskId; title: string; by: StaffId }): Promise<void>;
};
/** 交互态 —— app 注入 `interaction`(L3):控制权按态转交 */
export type InteractionPhaseSetter = {
setPhase(taskId: TaskId, phase: 'activating' | 'active' | 'awaiting-human' | 'awaiting-event' | 'transitioning' | 'terminal'): Promise<void>;
};