三面拆分⑭:宽进收敛 —— 起单不卡缺项,进活动受理归一,信息不全派补全单

用户定的逻辑:「调用活动,输入数据可以不定或者为空;进入活动后受理归一,
信息不全派补全单。」把收敛点从门口挪进活动 —— 三道门口闸全撤:

  · 触发装配:缺必填**不再入死信**(AssemblyResult 的 missing 改 partial,带上已装配的);
    装到多少带多少照样起单。死信只留给真错误(勾了确认没指定人这类配置错误)。
  · process.start:撤掉调用接口缺项闸 —— 从前缺一个字段整条流都起不来,
    现实是客户来信本来就常缺客户码,拦在门外等于要求外界替系统把信息备齐。
  · task.start(替代旧 AM 12.1「缺项不得启动」):缺必填的任务落成「等人」,
    缺的字段做成**补全卡**(elicitation,标签取 schema 的 title)发到负责人的
    任务对话流,并武装 human 监听(IntakeDesk 端口;不占活动的人卡预算)。
    答复只收契约声明过的键,合并进 data;齐了或答「没法填」才转 running 开跑;
    答一半就就缺的部分再派一张卡。等人期间谱一步不走、不扣步数。

  · 测试台预检改成**信息**不是闸:灯灭不拦起单,文案直说
    「抽不出的键由受理补全单找人收齐」。

5 条受理归一单测 + 全链实测:投一封只写「想问下工控板价格」的信(零键)→
流照起 → A1 落负责人工位、等人、补全卡在对话流 → 答卡(customerCode/rfqRef)→
任务醒、合并、谱跑 3 步到验收。修一处接线错误:监听 deadline 要 ISO-8601
时长(P2D)不是时刻,传错整个补全监听静默没装上。

trigger/task/process 三份 CONTRACT.md 与产品设计.md 同步(宽进纪律成文)。
五闸绿;契约快照随本提交入库。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
2026-07-29 13:46:13 +08:00
co-authored by Claude Opus 5
parent f9b2c07731
commit 8d50ceda6a
21 changed files with 391 additions and 65 deletions
+4
View File
@@ -24,6 +24,10 @@
被依赖: process, org(停用前置检查的在途任务), report, app
不变量(OS 04 / 4.3,AM 10 / 12.1):
- **受理归一**(2026-07-30 宽进,替代旧 AM 12.1「缺项不得启动」):输入可以不定或为空 ——
缺必填不拒单,任务落成 `awaiting-human`,缺的字段做成补全卡(`IntakeDesk` 端口)发给
负责人并武装 human 监听;答复只收契约声明过的键,合并进 data,齐了(或答「没法填」)
才转 running 开跑。等人期间谱一步不走、不扣步数;补全不占活动的人卡预算
- **恰一负责人**:任务必绑定恰一个 `ownerId`;改派**留痕并通知双方**
- **输入必过 schema**:`data` 缺项**不得启动**;`message` 计入初始观察但不得扩大授权
- **预算环外强制**:每一拍先 `judge` 再交引擎 —— 活动代码无从自查自守;
@@ -0,0 +1,159 @@
/**
* 受理归一(2026-07-30 宽进,用户定的逻辑)——
* 「调用活动,输入数据可以不定或者为空;进入活动后受理归一,信息不全派补全单。」
*
* 这里守四条:
* ① 缺必填**不拒单**:任务落成「等人」,补全卡发给负责人,谱一步不走;
* ② 补全答复只收契约声明过的键,合并进 data,齐了转 running;
* ③ 答一半:合并已给的、就缺的部分再派一张卡,还是等人;
* ④ 「没法填」不硬拦:人写明原因就带缺口开跑 —— 收敛是活动的事,不是门卫的事。
*/
import { describe, expect, it } from 'vitest';
import type { TaskId } from '@contracts';
import type { LandedFacts, Task } from '../contract.ts';
import { createTaskService } from '../internal/usecase/task-service.ts';
import type { TaskServiceDeps } from '../internal/usecase/task-service.ts';
type Harness = {
service: ReturnType<typeof createTaskService>;
asked: Array<{ fields: Array<{ key: string; label: string }> }>;
tasks: Map<string, Task>;
};
const INPUT_SCHEMA = {
type: 'object',
properties: {
customerCode: { type: 'string', title: '客户码' },
rfqRef: { type: 'string', title: '询价单号' },
subject: { type: 'string' },
},
required: ['customerCode', 'rfqRef'],
};
function harness(): Harness {
const asked: Harness['asked'] = [];
const tasks = new Map<string, Task>();
let seq = 0;
const deps: TaskServiceDeps = {
store: {
get: async (id) => tasks.get(id),
insert: async (t) => {
tasks.set(t.id, t);
},
update: async (id, patch) => {
const next = { ...tasks.get(id)!, ...patch };
tasks.set(id, next);
return next;
},
addConsumption: async (id) => tasks.get(id)!.used,
lastProgressAt: async () => new Date('2026-07-30T00:00:01.000Z'),
markProgress: async () => undefined,
listByOwner: async () => [],
},
activities: {
load: async () => ({
paradigm: 'flow' as const,
caps: { steps: 40, tokens: 200000, humanCalls: 2, activeMinutes: 60 },
envelope: {},
inputSchema: INPUT_SCHEMA,
businessModel: {},
stallAfter: 'PT30M',
}),
},
schemas: { validate: () => ({ ok: true, errors: [] }) },
engines: {
flow: { load: async () => undefined, advance: async () => ({ status: 'running' }), onEvent: async () => ({ status: 'running' }) },
loop: { load: async () => undefined, advance: async () => ({ status: 'running' }), onEvent: async () => ({ status: 'running' }) },
},
memory: { append: async () => undefined, hasHistory: async () => true, seal: async () => undefined, distill: async () => undefined },
landing: {
land: async () => ({ converged: true, emittedPorts: [] as string[], ledgerRowsMerged: 0, facts: {} as LandedFacts }),
},
listeners: { hasArmedListener: async () => true },
persistence: { hasDrafts: async () => false, hasRuntimeState: async () => true },
events: { publishTerminal: async () => undefined, publishControl: async () => undefined },
notifier: { notify: async () => undefined },
interaction: { setPhase: async () => undefined },
intake: {
askCompletion: async (input) => {
asked.push({ fields: input.fields.map((f) => ({ key: f.key, label: f.label })) });
},
},
clock: { now: () => new Date('2026-07-30T00:00:00.000Z') },
ids: { next: () => `t-${(seq += 1)}` },
};
return { service: createTaskService(deps), asked, tasks };
}
const START = {
activityId: 'a1',
ownerId: 's-1' as never,
title: '客户RFQ受理',
} as const;
describe('受理归一 —— 宽进,信息不全派补全单', () => {
it('① 缺必填不拒单:任务落成等人,补全卡带中文标签发出去', async () => {
const h = harness();
const task = await h.service.start({ ...START, data: { subject: '来信' } });
expect(task.runtime, '不开跑,先等人').toBe('awaiting-human');
expect(h.asked).toHaveLength(1);
expect(h.asked[0]!.fields).toEqual([
{ key: 'customerCode', label: '客户码' },
{ key: 'rfqRef', label: '询价单号' },
]);
});
it('② 补全到齐:合并进 data、转 running;不认契约之外的键', async () => {
const h = harness();
const task = await h.service.start({ ...START, data: {} });
await h.service.deliver(task.id as TaskId, {
kind: 'human',
ref: 'card-1',
payload: { values: { customerCode: 'C014', rfqRef: 'RFQ-1', hacker: '塞私货' } },
});
const after = h.tasks.get(task.id)!;
expect(after.runtime).toBe('running');
expect(after.data['customerCode']).toBe('C014');
expect(after.data['rfqRef']).toBe('RFQ-1');
expect('hacker' in after.data, '契约没声明的键不收').toBe(false);
});
it('③ 答一半:合并已给的,就缺的再派一张卡,还是等人', async () => {
const h = harness();
const task = await h.service.start({ ...START, data: {} });
await h.service.deliver(task.id as TaskId, {
kind: 'human',
ref: 'card-1',
payload: { values: { customerCode: 'C014' } },
});
const after = h.tasks.get(task.id)!;
expect(after.runtime).toBe('awaiting-human');
expect(after.data['customerCode']).toBe('C014');
expect(h.asked, '第二张卡只要还缺的').toHaveLength(2);
expect(h.asked[1]!.fields.map((f) => f.key)).toEqual(['rfqRef']);
});
it('④ 没法填:写明原因就带缺口开跑 —— 收敛是活动的事', async () => {
const h = harness();
const task = await h.service.start({ ...START, data: {} });
await h.service.deliver(task.id as TaskId, {
kind: 'human',
ref: 'card-1',
payload: { values: {}, reason: '客户没给单号,先按来信受理' },
});
expect(h.tasks.get(task.id)!.runtime).toBe('running');
});
it('输入齐全照旧直接开跑 —— 宽进不该让正常路多等一站', async () => {
const h = harness();
const task = await h.service.start({ ...START, data: { customerCode: 'C014', rfqRef: 'RFQ-1' } });
expect(task.runtime).toBe('running');
expect(h.asked).toHaveLength(0);
});
});
@@ -92,6 +92,7 @@ function harness(land: () => Promise<never> | Promise<{ converged: boolean; emit
events: { publishTerminal: async () => undefined, publishControl: async () => undefined },
notifier: { notify: async () => undefined },
interaction: { setPhase: async () => undefined },
intake: { askCompletion: async () => undefined },
clock: { now: () => new Date('2026-07-29T00:00:02.000Z') },
ids: { next: () => 'x' },
};
@@ -11,6 +11,7 @@ import type {
ActivityLoader,
Clock,
IdGenerator,
IntakeDesk,
InteractionPhaseSetter,
LandingGateway,
ListenerProbe,
@@ -39,10 +40,31 @@ export type TaskServiceDeps = {
readonly events: TaskEventPublisher;
readonly notifier: ReassignNotifier;
readonly interaction: InteractionPhaseSetter;
/**
* 受理补全台(2026-07-30 宽进):缺必填时把补全卡发到负责人的任务对话流,
* 并武装 human 监听等答复 —— 任务先「等人」,补齐了才开跑。
*/
readonly intake: IntakeDesk;
readonly clock: Clock;
readonly ids: IdGenerator;
};
/**
* 缺了哪些必填 —— 键从 JSON-Schema 的 required 来,标签取 properties 里的 title / label,
* 没有就用键名。补全卡要给人看的是「客户码」,不是 customerCode。
*/
function missingRequired(
inputSchema: Record<string, unknown>,
data: Record<string, unknown>,
): Array<{ key: string; label: string }> {
const required = Array.isArray(inputSchema['required']) ? (inputSchema['required'] as string[]) : [];
const props = (inputSchema['properties'] ?? {}) as Record<string, { title?: string; label?: string }>;
return required
.filter((key) => data[key] === undefined || data[key] === null || data[key] === '')
.map((key) => ({ key, label: props[key]?.title ?? props[key]?.label ?? key }));
}
export function createTaskService(deps: TaskServiceDeps): TaskService {
async function require(taskId: TaskId): Promise<Task> {
const task = await deps.store.get(taskId);
@@ -69,9 +91,13 @@ export function createTaskService(deps: TaskServiceDeps): TaskService {
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('; ')}`);
/*
* 受理归一(2026-07-30 宽进,替代旧 AM 12.1 的「缺项不得启动」):
* 输入**可以不定或为空** —— 缺必填不再拒单,而是任务先落成「等人」,
* 把缺的字段做成补全卡发给负责人;补齐(或答「没法填」)后才开跑。
* 收敛点从门口挪进活动:门口拒单,人连差什么都不知道;补全卡上写得明明白白。
*/
const missing = missingRequired(loaded.inputSchema, input.data);
const now = deps.clock.now();
const task: Task = {
@@ -82,7 +108,7 @@ export function createTaskService(deps: TaskServiceDeps): TaskService {
...(input.nodeId === undefined ? {} : { nodeId: input.nodeId }),
ownerId: input.ownerId,
title: input.title,
runtime: 'running',
runtime: missing.length > 0 ? 'awaiting-human' : 'running',
data: input.data,
envelope: loaded.envelope,
caps: loaded.caps,
@@ -99,6 +125,15 @@ export function createTaskService(deps: TaskServiceDeps): TaskService {
});
await engineOf(task).load(task.id, loaded.businessModel);
await deps.interaction.setPhase(task.id, 'active');
if (missing.length > 0) {
await deps.memory.append({
taskId: task.id,
kind: 'observation',
title: `受理归一:缺 ${missing.map((m) => m.key).join('、')} —— 已派补全单`,
detail: { missing: missing.map((m) => m.key) },
});
await deps.intake.askCompletion({ taskId: task.id, ownerId: task.ownerId, title: task.title, fields: missing });
}
await deps.store.markProgress(task.id, now);
return task;
},
@@ -222,6 +257,52 @@ export function createTaskService(deps: TaskServiceDeps): TaskService {
title: `事件到达:${event.kind}/${event.ref}`,
detail: event.payload,
});
/*
* 受理补全的答复(宽进):任务还在「等人」且谱一步没走 —— 这时候来的 human 事件
* 只可能是补全卡(谱没开跑,别的卡还发不出来)。答的值**只收契约声明过的键**,
* 合并进 data;齐了(或人答了「没法填」)就转 running,交给推进器开跑。
* 「没法填」不硬拦:人已把话说明,带着缺口进活动,收敛是活动的事。
*/
if (task.runtime === 'awaiting-human' && event.kind === 'human') {
const loaded = await deps.activities.load(task.activityId);
const stillMissing = missingRequired(loaded.inputSchema, task.data);
if (stillMissing.length > 0) {
const values = (event.payload['values'] ?? {}) as Record<string, unknown>;
const declared = new Set(stillMissing.map((m) => m.key));
const accepted = Object.fromEntries(
Object.entries(values).filter(([k, v]) => declared.has(k) && typeof v === 'string' && v.trim() !== ''),
);
const data = { ...task.data, ...accepted };
const remain = missingRequired(loaded.inputSchema, data);
const gaveUp = typeof event.payload['reason'] === 'string' && event.payload['reason'] !== '';
if (remain.length === 0 || gaveUp) {
await deps.store.update(taskId, { data, runtime: 'running' });
await deps.memory.append({
taskId,
kind: 'observation',
title: remain.length === 0 ? '受理归一:补全到齐,开跑' : `受理归一:${remain.map((m) => m.key).join('、')} 没法填 —— 带缺口开跑`,
detail: { merged: Object.keys(accepted), remain: remain.map((m) => m.key) },
});
} else {
await deps.store.update(taskId, { data });
await deps.memory.append({
taskId,
kind: 'observation',
title: `受理归一:收到 ${Object.keys(accepted).join('、') || '(空)'},还缺 ${remain.map((m) => m.key).join('、')}`,
detail: { merged: Object.keys(accepted), remain: remain.map((m) => m.key) },
});
await deps.intake.askCompletion({ taskId, ownerId: task.ownerId, title: task.title, fields: remain });
// 还在等人 —— 不 tick:引擎一步都不该走,谱要等补全齐了才开
await deps.store.markProgress(taskId, deps.clock.now());
const held = await require(taskId);
return { task: held, verdict: { kind: 'ok' }, progressed: false };
}
await deps.store.markProgress(taskId, deps.clock.now());
return this.tick(taskId);
}
}
await engineOf(task).onEvent(taskId, event);
await deps.store.markProgress(taskId, deps.clock.now());
return this.tick(taskId);
+13
View File
@@ -112,6 +112,19 @@ export type ReassignNotifier = {
};
/** 交互态 —— app 注入 `interaction`(L3):控制权按态转交 */
/**
* 受理补全台(2026-07-30 宽进):把缺的必填字段做成补全卡发到负责人的任务对话流,
* 并武装 human 监听等答复 —— app 注入 human-tool + event。
*/
export type IntakeDesk = {
askCompletion(input: {
taskId: string;
ownerId: string;
title: string;
fields: ReadonlyArray<{ key: string; label: string }>;
}): Promise<void>;
};
export type InteractionPhaseSetter = {
setPhase(taskId: TaskId, phase: 'activating' | 'active' | 'awaiting-human' | 'awaiting-event' | 'transitioning' | 'terminal'): Promise<void>;
};