harness 与工作台同日修:数据工单确认即入库 · 叫了人不再自动推进(两道结构闸)· 审核卡 = 终局验收卡结构 · 验收清单人点达标 · 迟到守卫不丢放行裁决 · 画面跟着这一拍发出去的卡走 · 成果详情只翻页 · 占位居中 · 进度条

human-tool / ledger / loop-engine / release / task / interaction 六模块与 app 层(desk 路由、engines、consumers、
data-order-landing、case-stamp、reset-runs、seed-activities、容器与 server 装配),web 的 desk-task
(ViewRegion / chat-bubbles / review-face)与 app.css、ui-kit 图标;契约 card / manifest;
迁移 0034_task_seq 与迁移日志;api-report 快照;CLAUDE.md 快照、debt、活动开发规范、HTML 报告规范同步。
逐条口径见 CLAUDE.md 快照(2026-08-27 下午至晚)。另一会话做的,一直留在工作树没入库;与同批另两条提交互相依赖。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0167qWjeyBwvfdGovd7FWbCu
This commit is contained in:
xcs
2026-08-27 23:20:16 +08:00
co-authored by Claude Fable 5
parent 909b1fe4b7
commit 7b85a79323
79 changed files with 3903 additions and 1297 deletions
+9 -1
View File
@@ -32,4 +32,12 @@
- **调人预算环外强制**:超过契约声明的打扰上限即 `HumanCallBudgetExceededError`,
活动代码无从自查自守
- **落地失败卡是执行工单的 preset**,不构成第五种卡
- **数据工单是执行工单的 `variant:'data'`**,同样不构成新的 mode:发卡 / 闸 / 回传全走执行工单那条路,只是对话区换一副面孔(2026-08-27)
- **人工验收清单点全才能通过**(2026-08-27 用户定):验收工单 / 审核卡带 `checklist`(剧本 `acceptanceChecklist`,
每份工单固定)时,「通过」的回传必须带 `checked ⊇ checklist`,否则 `checklist-incomplete` 拒回;驳回不看清单。
审核卡只拦「通过」那一个选项(`reviewApproveValue`:值叫 approve 的 / 推荐的 / 第一个)。点过的条目落进
`done.checked` / `chosen.checked` 留痕
- **`pendingCards(taskId)`**(2026-08-27):这一单正等人交回的卡 = 没办结的工单 + 没作答、没被顶替的对话卡
(`CardStore.listOpenChatCards` 按 `superseded_by is null` 取)。引擎每拍开头问它:有卡没答就不问模型
- **数据工单是执行工单的 `variant:'data'`**,同样不构成新的 mode:发卡 / 闸 / 回传全走执行工单那条路,只是对话区换一副面孔(2026-08-27)。
**确认即入库**(2026-08-27 下午)不在本模块:回传落定之后由 app 的回传路由调 `landDataOrder`
(`apps/api/src/app/data-order-landing.ts`)把这张卡 `gateLedgers` / 绑的表并进正式表 —— 本模块只管回传本身
@@ -3,11 +3,14 @@ import { describe, expect, it } from 'vitest';
import type {
AcceptanceWorkOrder,
ChatCard,
ChatCardReply,
DeliverableId,
ExecutionWorkOrder,
WorkOrder,
WorkOrderId,
WorkOrderReply,
} from '@contracts';
import { chatCardSchema, workOrderSchema } from '@contracts';
import {
allCriteriaGreen,
arrivalOf,
@@ -276,3 +279,57 @@ describe('录入卡分段:段号.列名', () => {
expect(split({ customer_code: 'CU-1', name: '老写法' }, 1)).toEqual({});
});
});
/*
* 人工验收清单要点全才能通过(2026-08-27 用户定:「只有人把这些判据都点成达标了,才能点击确认通过」)——
* 按钮灰掉是给人看的,这里是门:验收工单的「通过」与审核卡的「通过」选项同一道。驳回不拦。
*/
describe('人工验收清单:点全才能通过', () => {
const list = ['核对甲', '核对乙'];
// 夹具走契约 schema 造 —— 造出来的就是一张合法工单,不靠 as 硬转(§12.5)
const order: WorkOrder = workOrderSchema.parse({
id: 'wo-1',
at: '2026-08-27T10:00:00.000Z',
title: '评审放行与落账确认',
basis: [{ k: '来源', v: '收口' }],
mode: 'acceptance',
deliverable: 'landing',
criteria: [],
fields: [],
checklist: list,
});
const pass = (checked?: string[]) =>
({ mode: 'acceptance', workOrderId: 'wo-1', verdict: 'pass', values: {}, selectedPorts: {}, ...(checked === undefined ? {} : { checked }) }) as WorkOrderReply;
it('验收工单:少点一条就拒,点全放行;驳回不看清单', () => {
expect(checkWorkOrderReply(order, pass(['核对甲'])).map((v) => v.code)).toEqual(['checklist-incomplete']);
expect(checkWorkOrderReply(order, pass())).toHaveLength(1);
expect(checkWorkOrderReply(order, pass(list))).toEqual([]);
const redo = { mode: 'acceptance', workOrderId: 'wo-1', verdict: 'redo', values: {}, comment: '再看看', selectedPorts: {} } as WorkOrderReply;
expect(checkWorkOrderReply(order, redo)).toEqual([]);
});
it('审核卡(决策卡):选「通过」那一项要点全,驳回 / 自定义不看', () => {
const card: ChatCard = chatCardSchema.parse({
k: 'decision',
id: 'c1',
at: '2026-08-27T10:00:00.000Z',
ask: '请审核',
basis: [],
options: [
{ value: 'approve', label: '通过', recommended: true },
{ value: 'reject', label: '驳回' },
],
allowCustom: true,
variant: 'review',
checklist: list,
});
const reply = (value: string, checked?: string[], note?: string) =>
({ k: 'decision', cardId: 'c1', value, ...(checked === undefined ? {} : { checked }), ...(note === undefined ? {} : { note }) }) as ChatCardReply;
expect(checkChatCardReply(card, reply('approve')).map((v) => v.code)).toEqual(['checklist-incomplete']);
expect(checkChatCardReply(card, reply('approve', list))).toEqual([]);
expect(checkChatCardReply(card, reply('reject'))).toEqual([]);
// 自定义收尾项本就必附理由(既有规矩);清单不拦它
expect(checkChatCardReply(card, reply('custom:意见', undefined, '意见'))).toEqual([]);
});
});
@@ -34,6 +34,7 @@ function cardStore(used: number, sent: string[]): CardStore {
},
getChatCard: async () => undefined,
listChatCards: async () => [],
listOpenChatCards: async () => [],
updateChatCard: async () => undefined,
closeChatCard: async () => undefined,
humanCallCount: async () => used,
@@ -143,6 +143,11 @@ export type HumanToolService = {
/** 全部工单(含已办结)—— 工作成果要回看定稿的报告,只给 open 就永远是空的 */
allWorkOrders(taskId: TaskId): Promise<WorkOrder[]>;
chatCards(taskId: TaskId): Promise<ChatCard[]>;
/**
* **这一单正等人交回的卡**(2026-08-27):没办结的工单 + 没作答、没被顶替的对话卡。
* 引擎每拍开头问它 —— 有卡还没答,这一拍就不该问模型(叫了人就等人,不许自己往下推)。
*/
pendingCards(taskId: TaskId): Promise<Array<{ id: string; title: string; kind: 'work-order' | 'chat-card' }>>;
/** org 停用前置检查要问的「待答卡」 */
openCardsOf(staffId: StaffId): Promise<Array<{ cardId: string; title: string }>>;
};
@@ -99,6 +99,15 @@ export function createCardStore(db: Db): CardStore {
return rows.map((row) => chatCardSchema.parse(row.payload));
},
async listOpenChatCards(taskId) {
const rows = await db
.select()
.from(chatCardTable)
.where(and(eq(chatCardTable.taskId, taskId), isNull(chatCardTable.supersededBy)))
.orderBy(asc(chatCardTable.at));
return rows.map((row) => chatCardSchema.parse(row.payload));
},
async updateChatCard(card: ChatCard) {
await db.update(chatCardTable).set({ payload: card }).where(eq(chatCardTable.id, card.id));
},
@@ -131,6 +131,16 @@ export function checkWorkOrderReply(order: WorkOrder, reply: WorkOrderReply): Ca
* 为什么拦在这里而不是落地(land):回传是同步收单、落地在推进器里异步跑,
* 拦晚了就是「工单已答完、任务卡死、推进器循环报错」(实测)。
*/
/*
* **人工验收清单要点全才能通过**(2026-08-27 用户定):卡上那几条是人核这份工单要走的步骤,
* 按钮灰掉只是给人看的,这里才是门 —— 直调 API 把「通过」交回来也得把清单点全。
*/
if (reply.verdict === 'pass') {
const missing = checklistMissing((order as AcceptanceWorkOrder).checklist, reply.checked);
if (missing.length > 0) {
out.push({ code: 'checklist-incomplete', message: `验收清单还有 ${missing.length} 条待确认:${missing[0] ?? ''}` });
}
}
const choice = (order as AcceptanceWorkOrder).portChoice;
if (choice !== undefined && reply.verdict === 'pass') {
const picked = reply.selectedPorts[choice.group];
@@ -154,6 +164,20 @@ export function checkWorkOrderReply(order: WorkOrder, reply: WorkOrderReply): Ca
return out;
}
/** 清单里还没点成「达标」的那几条;没有清单就是空 */
export function checklistMissing(checklist: ReadonlyArray<string> | undefined, checked: ReadonlyArray<string> | undefined): string[] {
const done = new Set(checked ?? []);
return (checklist ?? []).filter((item) => !done.has(item));
}
/**
* 审核卡上哪一个选项是「通过」—— 与前端 `review-face.ts` 同一口径:值叫 `approve` 的,否则推荐的那个,否则第一个。
* 清单只拦这一个选项:驳回不需要把清单点全。
*/
export function reviewApproveValue(card: Extract<ChatCard, { k: 'decision' }>): string | undefined {
return (card.options.find((o) => o.value === 'approve') ?? card.options.find((o) => o.recommended === true) ?? card.options[0])?.value;
}
export function checkChatCardReply(card: ChatCard, reply: ChatCardReply): CardViolation[] {
const out: CardViolation[] = [];
if (card.id !== reply.cardId) return [{ code: 'mismatch', message: '回传的卡 ID 与卡不符' }];
@@ -161,6 +185,11 @@ export function checkChatCardReply(card: ChatCard, reply: ChatCardReply): CardVi
if (card.k === 'decision' && reply.k === 'decision') {
if (card.chosen !== undefined) return [{ code: 'already-answered', message: '该决策已选,只增不改' }];
// 审核卡穿验收工单的衣服:选「通过」也得把人工验收清单点全(与验收工单同一道门)
if (reply.value === reviewApproveValue(card)) {
const missing = checklistMissing(card.checklist, reply.checked);
if (missing.length > 0) return [{ code: 'checklist-incomplete', message: `验收清单还有 ${missing.length} 条待确认:${missing[0] ?? ''}` }];
}
const known = card.options.some((o) => o.value === reply.value);
// 自定义收尾项:allowCustom 打开时允许选项之外的处置(必附理由)
if (!known && !card.allowCustom) out.push({ code: 'unknown-option', message: `分支不在选项内:${reply.value}` });
@@ -197,7 +197,13 @@ export function createHumanToolService(deps: HumanToolServiceDeps): HumanToolSer
const at = deps.clock.now().toISOString();
const result = reply.mode === 'acceptance' ? reply.verdict : 'submitted';
const values = reply.mode === 'acceptance' ? reply.values : reply.values;
await deps.cards.markWorkOrderDone(reply.workOrderId, { at, by, result, values });
await deps.cards.markWorkOrderDone(reply.workOrderId, {
at,
by,
result,
values,
...(reply.mode === 'acceptance' && reply.checked !== undefined ? { checked: [...reply.checked] } : {}),
});
/*
* 出口:**算出来的优先,而且在服务端算数**(2026-08-02 修)。
@@ -365,6 +371,15 @@ export function createHumanToolService(deps: HumanToolServiceDeps): HumanToolSer
return deps.cards.listChatCards(taskId);
},
async pendingCards(taskId) {
const orders = (await deps.cards.listWorkOrders(taskId, true)).filter((o) => o.done === undefined);
const chats = (await deps.cards.listOpenChatCards(taskId)).filter((c) => !chatCardAnswered(c));
return [
...orders.map((o) => ({ id: String(o.id), title: o.title, kind: 'work-order' as const })),
...chats.map((c) => ({ id: String(c.id), title: c.ask, kind: 'chat-card' as const })),
];
},
async openCardsOf(staffId) {
return deps.cards.openCardsOf(staffId);
},
@@ -375,7 +390,13 @@ function applyChatReply(card: ChatCard, reply: ChatCardReply, by: StaffId, at: s
if (card.k === 'decision' && reply.k === 'decision') {
return {
...card,
chosen: { value: reply.value, at, by, ...(reply.note === undefined ? {} : { note: reply.note }) },
chosen: {
value: reply.value,
at,
by,
...(reply.note === undefined ? {} : { note: reply.note }),
...(reply.checked === undefined ? {} : { checked: [...reply.checked] }),
},
};
}
if (card.k === 'elicitation' && reply.k === 'elicitation') {
@@ -392,3 +413,9 @@ function applyChatReply(card: ChatCard, reply: ChatCardReply, by: StaffId, at: s
}
return card;
}
/** 对话卡答没答:决策卡看 chosen,其余(录入 / 补全 / 评价)看 done */
function chatCardAnswered(card: ChatCard): boolean {
if (card.k === 'decision') return card.chosen !== undefined;
return 'done' in card && card.done !== undefined;
}
+2
View File
@@ -17,6 +17,8 @@ export type CardStore = {
saveChatCard(taskId: TaskId, card: ChatCard, recipientId: StaffId): Promise<void>;
getChatCard(id: string): Promise<{ taskId: TaskId; card: ChatCard; recipientId: StaffId } | undefined>;
listChatCards(taskId: TaskId): Promise<ChatCard[]>;
/** 还开着的对话卡(没被新卡顶替、没作废)—— 「这一单有没有卡在等人」问的是它,不是全部历史 */
listOpenChatCards(taskId: TaskId): Promise<ChatCard[]>;
updateChatCard(card: ChatCard): Promise<void>;
closeChatCard(id: string, supersededBy: string, reason: string): Promise<void>;
@@ -42,7 +42,15 @@ describe('projectChat —— 对话流与工作日志同源不同筛选', () =>
expect(msg?.k === 'ai' ? msg.tag : undefined).toBe('guidance');
});
it('验收件标判据、执行件标指导', () => {
it('验收件标判据、执行件标指导 —— 认痕里的 mode,不认标题', () => {
const out = projectChat([
entry('card-sent', { id: 'a', title: '评审放行与落账确认(ACT-GATE-03)', detail: { kind: 'work-order', id: 'wo-a', mode: 'acceptance' } }),
entry('card-sent', { id: 'b', title: '请你验收前先补齐说明', detail: { kind: 'work-order', id: 'wo-b', mode: 'execution' } }),
]);
expect(out.map((m) => (m.k === 'ai' ? m.tag : undefined))).toEqual(['criterion', 'guidance']);
});
it('老痕没有 mode 才退回看标题', () => {
const out = projectChat([
entry('card-sent', { id: 'a', title: '请你验收:定价方案', detail: { kind: 'work-order', id: 'wo-a' } }),
entry('card-sent', { id: 'b', title: '请你执行:补齐说明', detail: { kind: 'work-order', id: 'wo-b' } }),
@@ -374,3 +374,64 @@ describe('data-file-of:这一格点开是一份资料文件', () => {
expect(out).not.toContain('onclick="x"');
});
});
/*
* 嵌套行模版 + `data-by` 父键过滤(2026-08-27,W05 差异评估结论审核「一个候选一张表」逼出来的):
* 外层按候选逐个复制,里层只列该候选名下的明细 —— 从前渲染器只认一层,里层的 data-rows 原样漏到成品里。
*/
describe('嵌套行模版与 data-by —— 一个候选一张表', () => {
// 外层容器用 <article>:行模版是 <section>,里面不会再出现 <article>(行容器的标签名不得在行模版内部再出现)
const html =
'<article data-rows="rel"><section><h2 data-slot="pn"></h2>' +
'<tbody data-rows="items" data-by="rel_id"><tr><td data-slot="name"></td><td data-slot="pn"></td></tr></tbody>' +
'</section></article>';
const scope = {
rel: [
{ rel_id: 'R1', pn: 'XL' },
{ rel_id: 'R2', pn: 'AP' },
],
items: [
{ rel_id: 'R1', name: '输出电压' },
{ rel_id: 'R2', name: '使能逻辑' },
{ rel_id: 'R1', name: '工作温度' },
],
};
it('里层只留与外层同键的行;父行的槽位仍填父值;data-by 属性随之消费', () => {
const out = renderView(html, scope);
const [first = '', second = ''] = out.split('</section>');
expect(out.split('<section>').length - 1).toBe(2); // 一个候选一个 section
expect(first).toContain('<h2>XL</h2>');
expect(first).toContain('<td>输出电压</td>');
expect(first).toContain('<td>工作温度</td>');
expect(first).not.toContain('使能逻辑');
expect(second).toContain('<h2>AP</h2>');
expect(second).toContain('<td>使能逻辑</td>');
expect(second).not.toContain('输出电压');
expect(out).not.toContain('data-by');
expect(out).not.toContain('data-rows');
});
it('里层槽位与父行同名时取里层那一行(先展开里层,再填父行)', () => {
const out = renderView(html, { ...scope, items: [{ rel_id: 'R1', name: 'n', pn: '子' }] });
expect(out).toContain('<h2>XL</h2>');
expect(out).toContain('<td>子</td>');
});
it('data-by="子列=父列":两边列名不同也对得上 —— 当查表用(整表里挑这一颗料的那一行)', () => {
const out = renderView(
'<ul data-rows="rel"><li><b data-slot="pn"></b><span data-rows="profile" data-by="internal_pn=pn"><i data-slot="brand"></i></span></li></ul>',
{ rel: [{ pn: 'XL' }, { pn: 'AP' }], profile: [{ internal_pn: 'AP', brand: 'Diodes' }, { internal_pn: 'XL', brand: 'XLSEMI' }] },
);
expect(out).toContain('<b>XL</b><span><i>XLSEMI</i></span>');
expect(out).toContain('<b>AP</b><span><i>Diodes</i></span>');
});
it('父值缺席一行不留;没有 data-by 的里层照旧全复制', () => {
const none = renderView(html, { ...scope, rel: [{ rel_id: '', pn: 'X' }] });
expect(none).toContain('<h2>X</h2>');
expect(none).not.toContain('<td>');
const all = renderView(html.replace(' data-by="rel_id"', ''), scope);
expect(all.split('使能逻辑').length - 1).toBe(2);
});
});
@@ -143,8 +143,16 @@ export function projectChat(entries: readonly NarrativeEntry[]): ChatMessage[] {
sayable = false;
continue;
}
// 验收件请人裁决 = 判据;执行件派工 = 指导
const tag = e.title.includes('验收') ? ('criterion' as const) : ('guidance' as const);
/*
* 验收件请人裁决 = 判据;执行件派工 = 指导 —— **认痕里的 `mode`,不认标题**(2026-08-27 用户:
* 「评审放行与落账确认的验收单怎么验收卡又变成老的验收卡了」)。从前判的是 `title.includes('验收')`,
* 而验收卡的题目当天改成了案例模型里那一项的名字「评审放行与落账确认(ACT-GATE-03)」——
* 四个字不在了,投影就把它当执行工单摆:通用「派出工单」气泡 + 判据列表,人工验收清单一条不见。
* 上面 card-reply 那一段早写过同一课(标题措辞是给人看的、随时会改),这条却还认字面。
* `mode` 是发卡时随 payload 一并入痕的结构化键;老痕没有它才退回标题。
*/
const mode = e.detail?.['mode'];
const tag = (mode === undefined ? e.title.includes('验收') : mode === 'acceptance') ? ('criterion' as const) : ('guidance' as const);
/*
* **一个气泡:结论 + 功能卡**(2026-08-01 用户定的形状)。
*
@@ -149,14 +149,24 @@ function paintClass(attrs: string, scope: Readonly<Record<string, unknown>>): st
* 每行内部的 `data-slot` 相对**该行对象**取值 —— 这样「逐条清单」不必写死条数。
*/
const ROWS = /<([a-zA-Z][\w-]*)\b([^>]*?)\bdata-rows\s*=\s*"([^"]+)"([^>]*)>([\s\S]*?)<\/\1>/g;
/**
* `data-by="子列"` / `data-by="子列=父列"`:**只留与外层那一行同键的行**(2026-08-27,W05 差异评估
* 「一个候选一张表」逼出来的)—— 行模版可以嵌套:外层按候选逐个复制,里层只列该候选名下的差异明细。
* 比的是字符串相等;父列的值取外层作用域(嵌套时外层那一行已铺进作用域)。父值取不到 = 一行不留:
* 没有父亲的孩子不该冒充谁的。它也能当**查表**用:`<span data-rows="parts.profile" data-by="internal_pn=candidate_internal_pn">`
* 从整表里挑出这一颗料那一行摆品牌 —— 仍是取值,不是算。
*/
const BY = /\s*\bdata-by\s*=\s*"([^"]+)"/;
export function renderRows(html: string, scope: Readonly<Record<string, unknown>>): string {
return html.replace(ROWS, (_all, tag: string, before: string, path: string, after: string, inner: string) => {
const raw = `${before}${after}`;
const attrs = raw.replace(/\s+/g, ' ').trim();
const by = BY.exec(raw)?.[1];
const attrs = raw.replace(BY, '').replace(/\s+/g, ' ').trim();
const open = `<${tag}${attrs === '' ? '' : ` ${attrs}`}>`;
const value = resolveSlot(scope, path);
const rows = Array.isArray(value) ? value : [];
const all = Array.isArray(value) ? value : [];
const rows = by === undefined ? all : byParent(all, by, scope);
/*
* 这一组行**可就地改**吗 —— 容器上的 `data-edit="域.对象"` 说了算。
* 是的话每一行多渲一份输入孪生件,并把行号钉在行上(写回时按它对行)。
@@ -173,13 +183,33 @@ export function renderRows(html: string, scope: Readonly<Record<string, unknown>
.map((row, index) => {
const one = (typeof row === 'object' && row !== null ? row : { value: row }) as Record<string, unknown>;
const twinned = editable ? twinInputs(inner, index) : inner;
return renderTemplate(uniqueNames(textareaCurrent(selectCurrent(fileLinks(twinned, one, address), one), one), index), one);
/*
* **里层的行模版先按这一行展开**(嵌套),再填这一行自己的标量槽 —— 顺序反了,
* 里层行内的槽位会被外层这一行填掉(与 renderView 「先展开行再填标量」同一条道理)。
* 里层作用域 = 外层作用域 + 这一行:`data-by` 的父列、里层要引整表的名字都从这里取。
*/
const nested = ROWS.test(twinned) ? renderRows(twinned, { ...scope, ...one }) : twinned;
ROWS.lastIndex = 0;
return renderTemplate(uniqueNames(textareaCurrent(selectCurrent(fileLinks(nested, one, address), one), one), index), one);
})
.join('');
return `${open}${body}</${tag}>`;
});
}
/** `data-by` 的过滤:子列的值 = 外层作用域里父列的值(字符串相等);父值缺席一行不留 */
function byParent(rows: readonly unknown[], by: string, scope: Readonly<Record<string, unknown>>): unknown[] {
const [childCol = '', parentCol = childCol] = by.split('=').map((s) => s.trim());
const parent = scope[parentCol];
if (parent === undefined || parent === null || parent === '') return [];
const want = String(parent);
return rows.filter((row) => {
if (typeof row !== 'object' || row === null) return false;
const v = (row as Record<string, unknown>)[childCol];
return v !== undefined && v !== null && String(v) === want;
});
}
/** `data-file-of="列名"` —— 这一格点开是一份资料文件,文件认哪一行由该列的值定 */
const FILE_OF = /\s*\bdata-file-of\s*=\s*"([^"]+)"/;
+14
View File
@@ -33,6 +33,15 @@
撤不到如实回 `false`(主键写法对不上是常见原因)—— 静默成功会让调用方以为撤掉了
- 正式版写入(system / release)**必**产生数据事件;草稿写**必不**产生
- `releaseWrite` 先过数据冲突检查:任一冲突即抛 `DraftConflictError`,不做部分合并
- **`releaseWrite(taskId, scope)` 是同一道放行写,门换成了数据工单的确认**(2026-08-27):
`scope.only` 只并那几张表(这张卡的提交闸盯的那几张,`gateLedgers` / 绑的 `blocks`);
并过的行**留在草稿层打 `landed` 记号**(`ledger.__draft.landed_at`)—— 它仍是本单写下的行
(`taskDrafts` / 任务视图照旧认它,引擎的 `mine:` / `_rows` 全靠这份),整单放行时不再写它、
不抛事件、不做冲突检查(`landedEarlierRows` 单独计数),只把表里那一份读回来当事实(出口载荷要它)。
再往已落地的行写一笔(人在件上改 / 模型 write),记号擦掉、基线换成落地后表里的值 —— 那一笔照旧等放行。
范围放行抛的数据事件**不指回本单**(它正等着自己的人事件;别单的监听照旧认正式版事件),
归因 `ledger-write:<scope.cause>`。试跑单(不落主数据)在这一道门上同样挡住:行留着、不打记号。
**不是第四种写,也不是绕门**:同一条冲突检查 → 原子合并 → 抛事件的路,调用方指不了清单没声明的表。
- **试跑单的主数据不进正式表**:`SimulationProbe` 说这一单是试跑时,
`kind = 'master'` 的草稿行不 upsert、不删、不抛数据事件,但仍进 `merged`(出口载荷取它);
`simulatedRows` 单独计数,不混进 `mergedRows`。单据(`record`)不受影响 —— 试跑仍是真跑
@@ -53,6 +62,11 @@
用注释而非另建元数据表:名字与表同生共死,不会对不上。
`comment on` 是工具语句不吃占位符,字面量在 adapter 内转义单引号。
近变(2026-08-27 下午):**数据工单确认即入库** —— `releaseWrite` 加可选 `scope`(`ReleaseScope`),
`DraftEntry` / `taskDrafts` 出参加 `landed`,`DraftStore` 加 `markLanded`,`ReleaseWriteResult` 加
`landedEarlierRows`(release 的放行痕据此分开说)。起因:W05 的数据工单卡上写着「确认后即入库」,
而草稿层通往正式表只有整单放行一道口子 —— 人建好的主数据要等整条评估放行才进库,试跑单永远不进。
近变(2026-08-27):**对象声明加 `createOnly`** —— 标了的表在本单**只建不改**:Agent 写入那一刻查正式表,
键已在库当场拒并指路(撤掉草稿里那一行)。数据工单「从零建主数据」的三张表用它。只管 Agent 的写路径
(`engines.draftWrite` 那道门),人在件上的保存不受此限。
@@ -29,6 +29,9 @@ const T3 = 't-it-3' as TaskId;
const T4 = 't-it-4' as TaskId;
const T5 = 't-it-5' as TaskId;
const T6 = 't-it-6' as TaskId;
const T7 = 't-it-7' as TaskId;
const T8 = 't-it-8' as TaskId;
const T9 = 't-it-9' as TaskId;
/** 哪几单算试跑 —— 由用例现填,替代 app 里那条查 `task.origin` 的 SQL */
const SIMULATED = new Set<string>();
@@ -73,7 +76,7 @@ afterAll(async () => {
.query(`delete from ledger.__object_meta where domain in ($1, $2)`, [DOMAIN, MASTER_DOMAIN])
.catch(() => undefined);
await pool
.query(`delete from ledger.__draft where task_id = any($1)`, [[T1, T2, T3, T4, T5, T6]])
.query(`delete from ledger.__draft where task_id = any($1)`, [[T1, T2, T3, T4, T5, T6, T7, T8, T9]])
.catch(() => undefined);
await pool.end();
}
@@ -194,6 +197,7 @@ describe('ledger · 真实 PG', () => {
expect(await ledger.releaseWrite(T1)).toEqual({
mergedRows: 0,
emittedEvents: 0,
landedEarlierRows: 0,
simulatedRows: 0,
merged: [],
});
@@ -371,3 +375,91 @@ describe('ledger · 试跑闸门', () => {
expect(master.rows[0]).toMatchObject({ name: '真单建的客户' });
}, 60_000);
});
/**
* 数据工单确认即入库(2026-08-27 用户定:「数据工单里填的数据,确认这个数据工单后就应该入库」)。
*
* 从前草稿层通往正式表只有整单放行这一道口子;数据工单让人从零建主数据,人一确认,
* 那几张表就该进库 —— 而这一单还要接着跑。判据:
* ① 只并范围内那几张表,范围外的草稿一字不动;
* ② 并过的行**留在草稿层、打记号**:`taskDrafts` 照旧列它(`landed: true`)、任务视图照旧有它 ——
* 引擎的 `mine:` / `_rows` 全靠这份,清掉它 P-08 的件当场变空、S1 的哨兵永远开不了;
* ③ 再确认一次是空操作(不再写、不再抛事件);整单放行时也不再写它、不再抛事件,只把它当事实读回来;
* ④ 落地之后再往那一行写一笔,记号擦掉、基线换新 —— 那一笔照旧等放行,且不被误判成「他单改过」;
* ⑤ 试跑单(不落主数据)在这一道门上同样挡住:行留在草稿层、不打记号,如实报 simulatedRows。
*/
describe('ledger · 数据工单确认即入库(范围放行)', () => {
const MASTER = { domain: MASTER_DOMAIN, object: MASTER_OBJECT };
const RECORD = { domain: DOMAIN, object: OBJECT };
const page = (address: { domain: string; object: string }, where: Record<string, string>) => ({
address,
where,
orderBy: [],
limit: 10,
offset: 0,
});
itDb()('①②③ 只并范围内的表;并过的行留在草稿层打记号;再确认与整单放行都不重写', async () => {
await ledger.draftWrite({ taskId: T7, address: MASTER, key: { customer_code: 'C-DO' }, patch: { customer_code: 'C-DO', name: '数据工单建的' } });
await ledger.draftWrite({ taskId: T7, address: RECORD, key: { pn: 'DO-PN' }, patch: { grade: 'A' } });
published = [];
const first = await ledger.releaseWrite(T7, { only: [MASTER], cause: 'data-order:wo-1' });
expect(first.mergedRows).toBe(1);
expect(first.landedEarlierRows).toBe(0);
expect(first.emittedEvents).toBe(1);
// 事件不指回本单(它正等着自己的人事件),归因认得出是哪张卡签下去的
expect(published[0]?.correlation.target).toBeUndefined();
expect(published[0]?.correlation.cause).toEqual({ kind: 'ledger-write', ref: 'data-order:wo-1' });
// ① 主数据进了正式表,单据没进
expect((await ledger.query(page(MASTER, { customer_code: 'C-DO' }))).rows[0]).toMatchObject({ name: '数据工单建的' });
expect((await ledger.query(page(RECORD, { pn: 'DO-PN' }))).rows).toEqual([]);
// ② 仍是本单写下的行:草稿层列着(带记号),任务视图有它
const drafts = await ledger.taskDrafts(T7);
expect(drafts.find((d) => d.object === MASTER_OBJECT)).toMatchObject({ landed: true, patch: { name: '数据工单建的' } });
expect(drafts.find((d) => d.object === OBJECT)).toMatchObject({ landed: false });
expect((await ledger.taskView(T7, page(MASTER, { customer_code: 'C-DO' }))).rows).toHaveLength(1);
// ③ 再确认一次 = 空操作
published = [];
const again = await ledger.releaseWrite(T7, { only: [MASTER], cause: 'data-order:wo-1' });
expect(again).toMatchObject({ mergedRows: 0, landedEarlierRows: 1, emittedEvents: 0 });
expect(published).toEqual([]);
// ③ 整单放行:只写剩下那一行,已落地的只当事实读回来
published = [];
const final = await ledger.releaseWrite(T7);
expect(final).toMatchObject({ mergedRows: 1, landedEarlierRows: 1, emittedEvents: 1 });
expect(published[0]?.correlation.target).toEqual({ kind: 'task', id: T7 });
expect(final.merged.map((m) => m.object).sort()).toEqual([OBJECT, MASTER_OBJECT].sort());
expect(final.merged.find((m) => m.object === MASTER_OBJECT)?.values).toMatchObject({ name: '数据工单建的' });
expect(await ledger.taskDrafts(T7)).toEqual([]);
}, 60_000);
itDb()('④ 落地之后再写一笔:记号擦掉、基线换新,那一笔等放行且不算冲突', async () => {
await ledger.draftWrite({ taskId: T8, address: MASTER, key: { customer_code: 'C-DO2' }, patch: { customer_code: 'C-DO2', name: '一版' } });
await ledger.releaseWrite(T8, { only: [MASTER], cause: 'data-order:wo-2' });
await ledger.draftWrite({ taskId: T8, address: MASTER, key: { customer_code: 'C-DO2' }, patch: { name: '二版' } });
expect((await ledger.taskDrafts(T8))[0]).toMatchObject({ landed: false, patch: { name: '二版' } });
// 基线是落地后表里的「一版」—— 不是落地前那份空基线,否则这里会被当成「他单改过」
expect(await ledger.checkConflicts(T8)).toEqual([]);
const final = await ledger.releaseWrite(T8);
expect(final).toMatchObject({ mergedRows: 1, landedEarlierRows: 0 });
expect((await ledger.query(page(MASTER, { customer_code: 'C-DO2' }))).rows[0]).toMatchObject({ name: '二版' });
}, 60_000);
itDb()('⑤ 试跑单:这一道门同样不落主数据 —— 行留在草稿层、不打记号、如实报数', async () => {
SIMULATED.add(T9);
await ledger.draftWrite({ taskId: T9, address: MASTER, key: { customer_code: 'C-DO-SIM' }, patch: { customer_code: 'C-DO-SIM', name: '试跑' } });
published = [];
const result = await ledger.releaseWrite(T9, { only: [MASTER], cause: 'data-order:wo-3' });
expect(result).toMatchObject({ mergedRows: 0, simulatedRows: 1, landedEarlierRows: 0, emittedEvents: 0 });
expect((await ledger.query(page(MASTER, { customer_code: 'C-DO-SIM' }))).rows).toEqual([]);
expect((await ledger.taskDrafts(T9))[0]).toMatchObject({ landed: false });
}, 60_000);
});
@@ -91,4 +91,17 @@ describe('detectConflicts —— 落地预检第五项', () => {
const officialByKey = new Map([[keyOf({ pn: 'LM358' }), { pn: 'LM358', eau: '1000' }]]);
expect(detectConflicts([draft('LM358', { eau: 2000 }, { eau: 1000 })], officialByKey)).toEqual([]);
});
/*
* **已经落过地的行不再参与冲突检查**(2026-08-27,数据工单确认即入库):
* 那一行在数据工单确认那一刻就进了正式表,放行时不会再写它 ——
* 别单后来改了它,也不是「我这一笔要盖掉别人」;拿它拦放行是把已发生的事当成待发生的。
*/
it('已落地(landed)的草稿行:别单后来改了也不算冲突 —— 放行不会再写它', () => {
const officialByKey = new Map([[keyOf({ pn: 'LM358' }), { pn: 'LM358', grade: 'B' }]]);
const landed: DraftEntry = { ...draft('LM358', { grade: 'C' }, { grade: 'C' }), landed: true };
expect(detectConflicts([landed], officialByKey)).toEqual([]);
// 同一行没标 landed 就照旧是冲突 —— 免得这条测试被「反正都不冲突」蒙混过去
expect(detectConflicts([draft('LM358', { grade: 'C' }, { grade: 'C' })], officialByKey)).toHaveLength(1);
});
});
+40 -4
View File
@@ -66,9 +66,30 @@ export type DraftWrite = {
readonly tombstone?: boolean;
};
/**
* **放行写的范围**(2026-08-27,数据工单确认即入库)。
*
* 不给 = 整单放行:草稿层全部并进正式表、清空。
* 给了 = 只并这几张表 —— 它们的门不是放行卡,是**数据工单的确认**(那张卡的提交闸
* 盯的就是这几张表:必填齐了才按得下确认,确认那一刻就入库)。并过的行**留在草稿层、
* 打上已落地记号**:它仍是本单写下的行(任务视图 / `mine:` / `_rows` 照旧认它),
* 只是整单放行时不再写它、不再为它抛事件。
* `cause` 落进数据事件的归因(`ledger-write:<cause>`),回看时认得出是哪张卡签下去的。
*/
export type ReleaseScope = {
readonly only: readonly ObjectAddress[];
readonly cause: string;
};
export type ReleaseWriteResult = {
readonly mergedRows: number;
readonly emittedEvents: number;
/**
* **早已落地、这次不再写的行数**(2026-08-27)—— 数据工单确认时并进去的那几行,
* 整单放行时只把它们当事实读回来(出口载荷要它们),不再写、不再抛事件。
* 放行痕上要分开说:「合并 N 行」里若混着这几行,那句话就多报了。
*/
readonly landedEarlierRows: number;
/**
* **试跑单被挡下的主数据行数** —— 合到出参里去,是因为界面不撒谎:
* 放行痕上写「合并 N 行」而其中几行根本没进表,那句话就是假的。
@@ -114,7 +135,13 @@ export type LedgerService = {
* 门拦住一件办错的事却不给改正的手段,那扇门就只是个死胡同。
*/
draftDrop(drop: { taskId: TaskId; address: ObjectAddress; key: Record<string, unknown> }): Promise<boolean>;
releaseWrite(taskId: TaskId): Promise<ReleaseWriteResult>;
/**
* 放行写。`scope` 见 `ReleaseScope`:不给 = 整单;给了 = 只并那几张表(数据工单确认那一道门),
* 并过的行留在草稿层打记号。**不是第四种写**,也不是绕门:同一条冲突检查 → 原子合并 → 抛事件的路,
* 只是门换成了那张卡的确认闸 —— 落哪几张表由发卡的人说了算(`gateLedgers`),调用方指不了别的表进来
* (清单没声明的表这一单本就写不进草稿层)。
*/
releaseWrite(taskId: TaskId, scope?: ReleaseScope): Promise<ReleaseWriteResult>;
/** 正式版查询 —— 跨任务只见过门的事实 */
query(query: LedgerQuery): Promise<LedgerPage>;
@@ -125,8 +152,17 @@ export type LedgerService = {
* 与 `taskView` 的区别:那个是「正式版 + 我的草稿」的合并视图,
* 这个只回我写过的那些行 —— 引擎求值要区分「这一单的事实」与「别人放行后的事实」。
*/
taskDrafts(taskId: TaskId): Promise<Array<{ domain: string; object: string; key: Record<string, unknown>; patch: Record<string, unknown> }>>;
/** 落地预检第五项:数据冲突检查 */
checkConflicts(taskId: TaskId): Promise<ConflictFinding[]>;
taskDrafts(taskId: TaskId): Promise<
Array<{
domain: string;
object: string;
key: Record<string, unknown>;
patch: Record<string, unknown>;
/** 已在数据工单确认那一道门上并进正式表(见 `ReleaseScope`);仍是本单写下的行 */
landed: boolean;
}>
>;
/** 落地预检第五项:数据冲突检查;`only` 给了就只查那几张表(与 `releaseWrite` 的 scope 同一口径) */
checkConflicts(taskId: TaskId, only?: readonly ObjectAddress[]): Promise<ConflictFinding[]>;
discardDrafts(taskId: TaskId): Promise<void>;
};
+1
View File
@@ -16,6 +16,7 @@ export type {
MergedObject,
ObjectAddress,
ObjectDeclaration,
ReleaseScope,
ReleaseWriteResult,
SchemaApplyResult,
SystemWrite,
@@ -316,7 +316,10 @@ create table if not exists ${DRAFT_TABLE} (
tombstone boolean not null default false,
updated_at timestamptz not null default now(),
primary key (task_id, domain, object, key_hash)
);`;
);
-- 已落地记号(2026-08-27,数据工单确认即入库):非空 = 这一行已在一道更早的门上并进正式表,
-- 放行时不再写它。加列走 if not exists —— 这张表由启动时的 DDL 自建,不走迁移。
alter table ${DRAFT_TABLE} add column if not exists landed_at timestamptz;`;
type DraftRow = {
task_id: string;
@@ -326,6 +329,7 @@ type DraftRow = {
patch: LedgerRow;
baseline: LedgerRow;
tombstone: boolean;
landed_at: Date | string | null;
};
function toEntry(row: DraftRow): DraftEntry & ObjectRef {
@@ -336,6 +340,7 @@ function toEntry(row: DraftRow): DraftEntry & ObjectRef {
patch: row.patch,
baseline: row.baseline,
tombstone: row.tombstone,
landed: row.landed_at !== null && row.landed_at !== undefined,
};
}
@@ -366,6 +371,8 @@ export function createDraftStore(runner: Queryable): DraftStore {
-- 后续步新写的字段就没有基线,冲突检查会把它们一律当成「他单改过」。
baseline = excluded.baseline || ${DRAFT_TABLE}.baseline,
tombstone = excluded.tombstone,
-- 再写一笔就不再是「已落地」:新的这一笔要等它自己的门(放行)
landed_at = null,
updated_at = now()`,
[
taskId,
@@ -394,6 +401,13 @@ export function createDraftStore(runner: Queryable): DraftStore {
[taskId, ref.domain, ref.object, keyOf(key)],
);
},
async markLanded(taskId, ref, key, baseline) {
await runner.query(
`update ${DRAFT_TABLE} set landed_at = now(), baseline = $5
where task_id = $1 and domain = $2 and object = $3 and key_hash = $4`,
[taskId, ref.domain, ref.object, keyOf(key), JSON.stringify(baseline)],
);
},
async clear(taskId) {
await runner.query(`delete from ${DRAFT_TABLE} where task_id = $1`, [taskId]);
},
@@ -13,6 +13,15 @@ export type DraftEntry = {
readonly tombstone: boolean;
/** 草稿建立时读到的基线值 —— 放行前的数据冲突检查用 */
readonly baseline: LedgerRow;
/**
* **这一行已经落过地了**(2026-08-27,数据工单确认即入库)——
* 它仍是「本单写下的行」(任务视图、`mine:`、`_rows` 这些读面照旧认它),
* 只是那一笔已经在一道更早的门(数据工单的确认)上并进了正式表:
* 放行时不再写它、不再为它抛事件、不拿它做冲突检查。
* 再往这一行写一笔(人在件上改、模型 write)就把这个记号擦掉 —— 新的那一笔照旧等放行。
* 缺省 = 没落过(老条目与不关心这一维的调用方不必填)。
*/
readonly landed?: boolean;
};
/** 主键的规范化串:字段名排序后拼接,保证同一行的键恒等 */
@@ -90,6 +99,8 @@ export function detectConflicts(
): ConflictFinding[] {
const findings: ConflictFinding[] = [];
for (const draft of drafts) {
// 已落地的行放行时不会再写 —— 别单后来改了它,也不是「我这一笔要盖掉别人」
if (draft.landed === true) continue;
const official = officialByKey.get(keyOf(draft.key));
if (official === undefined) continue; // 草稿新增的行,无从冲突
for (const field of Object.keys(draft.patch)) {
@@ -12,6 +12,7 @@ import type {
DraftWrite,
LedgerService,
ObjectAddress,
ReleaseScope,
ReleaseWriteResult,
SchemaApplyResult,
SystemWrite,
@@ -50,6 +51,20 @@ function groupDeclarations(declarations: readonly ObjectDeclaration[]): Map<stri
return groups;
}
/** 范围过滤:不给范围 = 全收 */
function inScope(only: readonly ObjectAddress[] | undefined): (draft: ObjectRef) => boolean {
if (only === undefined) return () => true;
const wanted = new Set(only.map((a) => `${a.domain}.${a.object}`));
return (draft) => wanted.has(`${draft.domain}.${draft.object}`);
}
/** 落地后的基线:只取这一笔碰过的那几列,值取表里那一份 */
function baselineOf(stored: Record<string, unknown>, patch: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const field of Object.keys(patch)) out[field] = stored[field];
return out;
}
export function createLedgerService(deps: LedgerServiceDeps): LedgerService {
function dataEvent(
ref: ObjectRef,
@@ -181,13 +196,29 @@ export function createLedgerService(deps: LedgerServiceDeps): LedgerService {
return true;
},
/** ③ 放行写:冲突检查 → 原子合并 → 此刻抛事件 */
async releaseWrite(taskId): Promise<ReleaseWriteResult> {
const conflicts = await this.checkConflicts(taskId);
/**
* ③ 放行写:冲突检查 → 原子合并 → 此刻抛事件。
*
* `scope` 给了就是**数据工单确认那一道门**(2026-08-27 用户定:「数据工单里填的数据,
* 确认这个数据工单后就应该入库」):只并那几张表,并过的行留在草稿层打「已落地」记号 ——
* 它仍是本单写下的行(`mine:` / `_rows` / 任务视图照旧认它,不然 P-08 那张件当场变空、
* S1 的哨兵 `parts_profile_rows >= created` 永远开不了),只是整单放行时不再写它。
*/
async releaseWrite(taskId, scope?: ReleaseScope): Promise<ReleaseWriteResult> {
const conflicts = await this.checkConflicts(taskId, scope?.only);
if (conflicts.length > 0) throw new DraftConflictError(conflicts);
const drafts = await deps.drafts.list(taskId);
if (drafts.length === 0) return { mergedRows: 0, emittedEvents: 0, simulatedRows: 0, merged: [] };
const drafts = (await deps.drafts.list(taskId)).filter(inScope(scope?.only));
const nothing: ReleaseWriteResult = { mergedRows: 0, emittedEvents: 0, landedEarlierRows: 0, simulatedRows: 0, merged: [] };
if (drafts.length === 0) return nothing;
const scoped = scope !== undefined;
const causeRef = scoped ? scope.cause : `release:${taskId}`;
/*
* 事件指不指回本单:整单放行指回(它正要收尾,与从前一样);数据工单那一道**不指回** ——
* 本单此刻正等着自己的人事件(卡的回传)叫醒,再喂它七八条 data 事件只会在日志里结巴,
* 而它对这些行的「读己之写」从没断过。别单的监听照旧按正式版事件认(跨任务只认过门的事实)。
*/
const target = scoped ? undefined : taskId;
/*
* **试跑单不落主数据** —— 挡在这一处,是因为这里是草稿层通往世界的唯一一道口子。
@@ -208,6 +239,7 @@ export function createLedgerService(deps: LedgerServiceDeps): LedgerService {
const heldBack = (draft: { domain: string; object: string }): boolean =>
simulated && masters.has(`${draft.domain}.${draft.object}`);
let simulatedRows = 0;
let landedEarlierRows = 0;
const pending: EnvelopeDraft[] = [];
/*
@@ -224,6 +256,17 @@ export function createLedgerService(deps: LedgerServiceDeps): LedgerService {
await deps.store.transaction(async (store, draftStore) => {
for (const draft of drafts) {
const ref: ObjectRef = { domain: draft.domain, object: draft.object };
if (draft.landed === true) {
/*
* 早已在数据工单确认那一道门上落过地:这次不写、不抛事件、不计入合并数;
* 只把表里那一份读回来当事实(出口载荷要它)。表里读不到(别单后来删了)就不摆。
*/
landedEarlierRows += 1;
if (draft.tombstone) continue;
const current = (await store.selectByKeys(ref, [draft.key]))[0];
if (current !== undefined) landed.push({ domain: draft.domain, object: draft.object, key: draft.key, values: current });
continue;
}
if (heldBack(draft)) {
simulatedRows += 1;
if (draft.tombstone) continue; // 试跑不删主数据,连一行也不许少
@@ -243,9 +286,8 @@ export function createLedgerService(deps: LedgerServiceDeps): LedgerService {
}
if (draft.tombstone) {
await store.deleteRow(ref, draft.key);
pending.push(
dataEvent(ref, draft.key, [], 'delete', taskId, { kind: 'ledger-write', ref: `release:${taskId}` }),
);
pending.push(dataEvent(ref, draft.key, [], 'delete', target, { kind: 'ledger-write', ref: causeRef }));
if (scoped) await draftStore.markLanded(taskId, ref, draft.key, {});
continue;
}
const existing = await store.selectByKeys(ref, [draft.key]);
@@ -264,18 +306,24 @@ export function createLedgerService(deps: LedgerServiceDeps): LedgerService {
draft.key,
Object.keys(draft.patch),
existing.length === 0 ? 'insert' : 'update',
taskId,
{ kind: 'ledger-write', ref: `release:${taskId}` },
target,
{ kind: 'ledger-write', ref: causeRef },
),
);
/*
* 数据工单那一道门:条目留着、打记号,基线换成落地后表里的值(PG 归型后的那一份)——
* 之后再往这一行写一笔,冲突检查比的才是「我改之前表里是什么」。
*/
if (scoped) await draftStore.markLanded(taskId, ref, draft.key, baselineOf(stored ?? draft.patch, draft.patch));
}
await draftStore.clear(taskId);
if (!scoped) await draftStore.clear(taskId);
});
for (const envelope of pending) await deps.events.publish(envelope);
return {
mergedRows: drafts.length - simulatedRows,
mergedRows: drafts.length - simulatedRows - landedEarlierRows,
emittedEvents: pending.length,
landedEarlierRows,
simulatedRows,
merged: landed,
};
@@ -299,11 +347,11 @@ export function createLedgerService(deps: LedgerServiceDeps): LedgerService {
async taskDrafts(taskId) {
const drafts = await deps.drafts.list(taskId);
return drafts.map((d) => ({ domain: d.domain, object: d.object, key: d.key, patch: d.patch }));
return drafts.map((d) => ({ domain: d.domain, object: d.object, key: d.key, patch: d.patch, landed: d.landed === true }));
},
async checkConflicts(taskId) {
const drafts = await deps.drafts.list(taskId);
async checkConflicts(taskId, only) {
const drafts = (await deps.drafts.list(taskId)).filter(inScope(only));
if (drafts.length === 0) return [];
const byObject = new Map<string, Array<(typeof drafts)[number]>>();
for (const d of drafts) {
+9
View File
@@ -69,5 +69,14 @@ export type DraftStore = {
* (唯一能改的是那个数,改了别的判据又不对),13 次拒回、23 拍、234k token 才熔断。
*/
drop(taskId: string, ref: ObjectRef, key: LedgerKey): Promise<void>;
/**
* 给本单草稿层的一行打上「已落地」记号(2026-08-27,数据工单确认即入库)。
*
* 那一行刚经一道更早的门并进了正式表:条目**留着**(它仍是本单写下的行,
* 任务视图 / `mine:` / `_rows` 照旧认它),只是放行时不再写它。
* `baseline` 换成落地后正式表里的值 —— 之后再往这一行写一笔(记号随之擦掉),
* 冲突检查比的才是「我改之前表里是什么」,而不是落地前那份早已过时的基线。
*/
markLanded(taskId: string, ref: ObjectRef, key: LedgerKey, baseline: LedgerRow): Promise<void>;
clear(taskId: string): Promise<void>;
};
+18 -2
View File
@@ -59,8 +59,16 @@
(A1 一单连拒五拍,五次全是「S3_confirm 要先 start」)—— 那道墙保护不了任何东西:
start 是**模型自己的**动作,同一串连发即过,拦下只是逼它多演一步学不稳的礼仪。
人工激活真正的分量原样保留:**引擎不因哨兵满足替它启动**(哨兵只推到
Available/Enabled),**也不给 doneWhen 替人收**(自动收步跳过人工激活项)——
收它的只能是模型自己的 finish。`start` 仍是合法行动,只是可省。
Available/Enabled),**人答卡之前不给 doneWhen 替人收**。`start` 仍是合法行动,只是可省。
- **人工激活的项,人答过卡之后照常求 doneWhen**(2026-08-27 晚,用户:「根因修了吗?不要只修表象」):
自动收步对人工激活项的门槛是 `worldTouched`(卡真发过)且此刻没有未答的卡 —— 卡没发过的
doneWhen 再真也不收(那才是替人签字);答过之后「办完了没有」若是基座数得出来的事实
(W05 两张数据工单:人填的值已入库、必填不缺),引擎自己收,不再指望模型记得 finish
(剧本两处写死「先 finish 再发下一张」,模型照样忘,两步同时 Active、件停在上一步)。
拍首扫一遍,**拍尾再扫一遍**(人答卡那一拍里模型写完东西 doneWhen 才真;这一拍刚发卡的项排除;
提交放行那一拍不扫,免得把在途验收信封擦掉)。没声明 doneWhen 的人工激活项一个字不变。
- **画面跟着这一拍发出去的卡走**(同日):human / entry / decide 把「卡是哪一步的」交回拍内,
那一步声明了画面就推它,压过「第一步没干完的」—— 卡是发给人的那件事,件必须是那件事的件。
- **里程碑前提达成即达成**(2026-07-30,`achieveMilestones`):CMMN 的里程碑没有执行体,
也没有谁去按它。从前只有 `onEvent(ref = 里程碑 id)` 会 occur,而系统里没有任何东西
发这种事件 —— 于是里程碑永远 `Available`:既是必做项(堵死 autoComplete),
@@ -128,6 +136,14 @@
立了没人叫醒的监听不是等待,是失联(实测:模型在依赖断点上临场立 data 监听,
而那个活动一个监听都没声明,单子永久挂在 `awaiting-event`)
近变(2026-08-27 傍晚):**叫了人就等人** —— `runTurn` 在途验收之后、判齐快道之前先问 `cards.unanswered(taskId)`:
这一单还有卡没交回(工单没办结 / 对话卡没作答),这一拍不问模型,留一条内部痕、状态仍是 awaiting。
不管是谁叫醒的(迟到的回传事件、另一台后端的推进器、手动 wake)都推不动它。起因:S1 的卡发出 0.3 秒后,
上一张卡回传的事件才投到,再一拍模型照事实行「已经发过卡,人已经被问过了」把没人答的 S1 收掉了。
`cards.decide` 另多一项可选 `variant`(引擎按剧本定面孔,点名的项挂不到就按当下在办的那一步)与 `checklist`
(剧本 `acceptanceChecklist`,人工验收清单,与面孔同一条路带上卡);终局验收卡的清单由组装层在 submit 时从
剧本里取(声明了清单且不是审核卡的那一步)。
近变(2026-08-27):`cards.issue` 的入参多一项可选 `itemId`(这张卡挂在哪个计划项上)—— 组装层据此查剧本声明
(`PlanStep.cardVariant`:这一步派的是数据工单),**不靠模型抄卡骨架**(实测它会把 `variant` 抄丢)。
引擎自己发的卡(验收 / 兜底)没有项,不传。
@@ -65,3 +65,31 @@ describe('阶段进度 —— 三段激活态 + 一格收口 = x/4', () => {
expect(phaseProgress([], at([]), false)).toBeUndefined();
});
});
/*
* 被跳过的项不许把进度条钉死(2026-08-27 用户实测:W05 都在库时数据工单两项一直 Available,
* 条永远停在 1/6,而单子早已办到审核)。案子走到哪一段,条就到哪一段。
*/
describe('被跳过的项不钉死进度条', () => {
const W05: CasePhase[] = [
{ name: '启动与存在性检查', items: ['S0_bind', 'S0d_p08_master', 'S0d_p10_document'] },
{ name: '评估输入确认', items: ['S1_input_confirm'] },
{ name: '差异评估', items: ['S2_normalize', 'S3_assess'] },
{ name: 'FAE 审核', items: ['S4_review'] },
];
it('数据工单两项从没进过场(Available),S1 一活起来条就到第二段;S3 办着就到第三段', () => {
const atS1 = items({ S0_bind: 'Completed', S0d_p08_master: 'Available', S0d_p10_document: 'Available', S1_input_confirm: 'Active' });
expect(phaseProgress(W05, atS1, false)).toMatchObject({ index: 2, total: 5, name: '评估输入确认' });
const atS3 = items({ S0_bind: 'Completed', S0d_p08_master: 'Available', S0d_p10_document: 'Available', S1_input_confirm: 'Completed', S2_normalize: 'Completed', S3_assess: 'Active' });
expect(phaseProgress(W05, atS3, false)).toMatchObject({ index: 3, name: '差异评估' });
});
it('走过的那一段全收完、下一段还没动:进下一段;最后一段收完:收口格', () => {
const done2 = items({ S0_bind: 'Completed', S0d_p08_master: 'Available', S0d_p10_document: 'Available', S1_input_confirm: 'Completed', S2_normalize: 'Available' });
expect(phaseProgress(W05, done2, false)).toMatchObject({ index: 3, name: '差异评估' });
const all = items({ S0_bind: 'Completed', S0d_p08_master: 'Available', S0d_p10_document: 'Available', S1_input_confirm: 'Completed', S2_normalize: 'Completed', S3_assess: 'Completed', S4_review: 'Completed' });
expect(phaseProgress(W05, all, false)).toMatchObject({ index: 5, name: CLOSING_PHASE });
});
it('一项都没动过:仍停在第一段(老规矩)', () => {
expect(phaseProgress(W05, items({ S0_bind: 'Available', S1_input_confirm: 'Available' }), false)).toMatchObject({ index: 1 });
});
});
@@ -71,6 +71,8 @@ function harness(
viewFn?: (written: Harness['written']) => Record<string, unknown>;
/** 这个活动清单声明过哪些监听(不给就当它声明了 data —— 老测试照跑) */
declaredListeners?: ReadonlyArray<{ eventType: string; keyFilter: Record<string, string> }>;
/** 这一单还有哪几张卡在等人交回(不给 = 一张都没有 —— 老测试照跑) */
unanswered?: ReadonlyArray<{ id: string; title: string }>;
} = {},
): Harness {
const issued: Harness['issued'] = [];
@@ -157,6 +159,11 @@ function harness(
issued.push({ workOrder: 'elicitation', card: { ask: input.ask, fields: input.fields.map((f) => f.key) } });
return { id: `card-${issued.length}` };
},
decide: async (input: { ask: string; itemId?: string; variant?: string; checklist?: ReadonlyArray<string> }) => {
issued.push({ workOrder: 'decision', card: { ask: input.ask, itemId: input.itemId, variant: input.variant, checklist: input.checklist } });
return { id: `card-${issued.length}` };
},
unanswered: async () => over.unanswered ?? [],
} as never,
listeners: {
register: async (input: { eventType: string }) => {
@@ -1333,6 +1340,96 @@ describe('画面不再静默丢帧', () => {
expect(h.pushed).toEqual(['step2-view']);
});
it('两步同时 Active、这一拍发的是第二步的卡 → 推第二步的画面:件跟着卡走,不跟「第一步没干完的」', async () => {
const TWO: CaseModel = {
name: '两步',
autoComplete: true,
items: [
{ id: 'i1', name: '数据工单 P-08', type: 'humanTask', discretionary: false, entryCriteria: [], exitCriteria: [] },
{ id: 'i2', name: '数据工单 P-10', type: 'humanTask', discretionary: false, entryCriteria: [], exitCriteria: [] },
],
sentries: [],
} as never;
const h = harness({
parsed: TWO,
// 模型没收掉第一步(剧本让它先 finish,它没照办)—— 第一步的卡人已答过,第二步刚站上场
runtime: { items: [{ id: 'i1', state: 'Active', worldTouched: true }, { id: 'i2', state: 'Active' }] } as never,
templates: ['step1-view', 'step2-view'],
steps: [
{ item: 'i1', intent: '办第一步', guide: '做法', writes: [], reads: [], tools: [], view: 'step1-view' },
{ item: 'i2', intent: '办第二步', guide: '做法', writes: [], reads: [], tools: [], view: 'step2-view' },
],
actions: [{ kind: 'human', itemId: 'i2', workOrder: 'execution', card: { title: '数据工单 · 原厂技术资料创建(P-10)', ask: '请补资料' } }],
});
await h.runner.runTurn(TASK);
expect(h.issued).toHaveLength(1);
expect(h.pushed).toEqual(['step2-view']);
});
/*
* **人工激活的项:人答过卡之后照常求 doneWhen**(2026-08-27 晚,用户:「根因修了吗?不要只修表象」)。
* W05 两张数据工单实测:卡人答了、数据入库了,模型没 finish 上一步就发了下一张 —— 两步同时 Active,
* 件停在上一步。办完了没有是引擎手里现成的事实,不该指望模型记得收。门槛仍是 worldTouched:
* 卡没发过的人工激活项,doneWhen 再真也不收 —— 那才是替人签字。
*/
it('人工激活的项:卡真发过(worldTouched)且 doneWhen 为真 → 拍首引擎自己收,不问模型', async () => {
const h = harness({
parsed: STAGE_MODEL,
runtime: { ...READY_RUNTIME2, items: READY_RUNTIME2.items.map((i) => (i.id === 'GATED' ? { ...i, state: 'Active', worldTouched: true } : i)) } as never,
steps: [{ item: 'GATED', intent: '派单', guide: '做法', writes: [], reads: [], tools: [], view: 'x', doneWhen: 'landed_rows > 0 and landed_missing = 0' }],
sfeelCondition: () => true,
action: { kind: 'judge', criterion: 'x', satisfied: true, rationale: 'y' },
});
const outcome = await h.runner.runTurn(TASK);
expect(outcome.runtime.items.find((i) => i.id === 'GATED')?.state).toBe('Completed');
expect(h.appended.some((e) => e.title.startsWith('自动收步:GATED'))).toBe(true);
});
it('人工激活的项:卡从没发过 → doneWhen 为真也不收(引擎不替人签字)', async () => {
const h = harness({
parsed: STAGE_MODEL,
runtime: READY_RUNTIME2,
steps: [{ item: 'GATED', intent: '派单', guide: '做法', writes: [], reads: [], tools: [], view: 'x', doneWhen: 'landed_rows > 0' }],
sfeelCondition: () => true,
action: { kind: 'judge', criterion: 'x', satisfied: true, rationale: 'y' },
});
const outcome = await h.runner.runTurn(TASK);
expect(outcome.runtime.items.find((i) => i.id === 'GATED')?.state).not.toBe('Completed');
expect(h.appended.some((e) => e.title.startsWith('自动收步:GATED'))).toBe(false);
});
it('拍尾也扫:这一拍写完使上一步(人答过卡)的 doneWhen 为真、并发了下一步的卡 → 上一步当场收掉,下一步的卡不被误收', async () => {
const TWO: CaseModel = {
name: '两张数据工单',
autoComplete: true,
items: [
{ id: 'i1', name: 'P-08', type: 'humanTask', manualActivation: true, discretionary: false, entryCriteria: [], exitCriteria: [] },
{ id: 'i2', name: 'P-10', type: 'humanTask', manualActivation: true, discretionary: false, entryCriteria: [], exitCriteria: [] },
],
sentries: [],
} as never;
const h = harness({
parsed: TWO,
runtime: { items: [{ id: 'i1', state: 'Active', worldTouched: true }, { id: 'i2', state: 'Available' }] } as never,
templates: ['step1-view', 'step2-view'],
steps: [
{ item: 'i1', intent: '办第一步', guide: '做法', writes: [], reads: [], tools: [], view: 'step1-view', doneWhen: 'record_rows > 0' },
{ item: 'i2', intent: '办第二步', guide: '做法', writes: [], reads: [], tools: [], view: 'step2-view', doneWhen: 'record_rows > 0' },
],
actions: [
{ kind: 'write', itemId: 'i1', address: 'quote_intake.record', rows: [{ key: { pn: 'X' }, patch: { eau: 1 } }] },
{ kind: 'human', itemId: 'i2', workOrder: 'execution', card: { title: '数据工单 · 原厂技术资料创建(P-10)', ask: '请补资料' } },
],
viewFn: (written) => ({ record_rows: written.length }),
sfeelCondition: (_expr, scope) => Number(scope['record_rows'] ?? 0) > 0,
});
const outcome = await h.runner.runTurn(TASK);
expect(outcome.runtime.items.find((i) => i.id === 'i1')?.state).toBe('Completed');
// 这一拍刚发卡的那一步:doneWhen 同样为真,但卡还没人答 —— 不许收
expect(outcome.runtime.items.find((i) => i.id === 'i2')?.state).toBe('Active');
expect(h.pushed).toEqual(['step2-view']);
});
it('活步都收完 → 一帧都不推,最后摆上去的那一张(放行页)留在档案里', async () => {
const h = harness({
parsed: OPEN_MODEL,
@@ -1886,3 +1983,129 @@ describe('worldTouched 要落进保存的局面', () => {
expect(gated?.worldTouched, '人答回来之后,发过卡这件事被抹掉了').toBe(true);
});
});
/*
* 审核卡的面孔由引擎按剧本定(2026-08-27 用户:「这个验收卡又变成需要你定了」):
* 从前组装层只按行动里的 itemId 查剧本的 `cardVariant`,模型漏了 itemId(或给的项挂不上)
* 卡就退成「需要你定」。现在引擎把面孔递过去:点名的项优先,点不到就按当下在办的那一步。
*/
describe('决策卡的面孔按剧本定,不靠模型给对 itemId', () => {
const decide = (itemId?: string) => ({
kind: 'decide',
...(itemId === undefined ? {} : { itemId }),
ask: '请审核',
options: [
{ value: 'approve', label: '通过' },
{ value: 'reject', label: '驳回' },
],
});
const reviewStep = {
item: 'FREE',
intent: '审核',
guide: '做法',
writes: [],
reads: [],
tools: [],
view: 'x',
cardVariant: 'review',
acceptanceChecklist: ['核对甲', '核对乙'],
};
it('模型把卡挂到了别的项上(那一步没声明面孔):按当下在办的那一步 —— 剧本说 review 就是验收卡', async () => {
const plainStep = { item: 'GATED', intent: '别的活', guide: '做法', writes: [], reads: [], tools: [], view: 'y' };
const h = harness({ parsed: STAGE_MODEL, runtime: READY_RUNTIME2, action: decide('GATED'), steps: [reviewStep, plainStep] });
const out = await h.runner.runTurn(TASK);
const card = h.issued.find((c) => c.workOrder === 'decision');
expect(card, JSON.stringify({ status: out.status, appended: h.appended.slice(-3) })).toBeDefined();
expect(card?.card['variant']).toBe('review');
});
it('模型点名了那一项:照它查;这一步剧本没声明 cardVariant 就不给面孔(普通决策卡)', async () => {
const named = harness({ parsed: STAGE_MODEL, runtime: READY_RUNTIME2, action: decide('FREE'), steps: [reviewStep] });
await named.runner.runTurn(TASK);
expect(named.issued.find((c) => c.workOrder === 'decision')?.card['variant']).toBe('review');
// 人工验收清单随面孔一起按剧本带上卡
expect(named.issued.find((c) => c.workOrder === 'decision')?.card['checklist']).toEqual(['核对甲', '核对乙']);
const plain = harness({
parsed: STAGE_MODEL,
runtime: READY_RUNTIME2,
action: decide('FREE'),
steps: [{ ...reviewStep, cardVariant: undefined }],
});
await plain.runner.runTurn(TASK);
expect(plain.issued.find((c) => c.workOrder === 'decision')?.card['variant']).toBeUndefined();
});
});
/*
* 叫了人就等人(2026-08-27,用户:「你叫了人我都没填你就直接推进了,不能再有叫了人又继续自动推进的问题」):
* 有卡没交回,这一拍不问模型 —— 迟到的回传事件、另一台后端的推进器、手动 wake 都推不动它。
*/
describe('有卡等人交回:这一拍不问模型', () => {
const finishFree = { kind: 'finish', itemId: 'FREE', rationale: '写齐了' };
it('卡没答:不跑模型、不写、不发新卡,留一条内部痕,状态仍是等人', async () => {
const h = harness({
parsed: STAGE_MODEL,
runtime: READY_RUNTIME2,
action: finishFree,
unanswered: [{ id: 'wo-1', title: '评估输入与资料补齐确认' }],
});
const out = await h.runner.runTurn(TASK);
expect(out.status).toBe('awaiting');
expect(h.decided).toEqual([]);
expect(h.written).toEqual([]);
expect(h.issued).toEqual([]);
expect(out.runtime.items.find((i) => i.id === 'FREE')?.state).not.toBe('Completed');
expect(h.appended.some((e) => e.kind === 'observation' && e.title.includes('等人交回:评估输入与资料补齐确认'))).toBe(true);
});
it('卡答完了(没有在等的卡):照常问模型', async () => {
const h = harness({ parsed: STAGE_MODEL, runtime: READY_RUNTIME2, action: finishFree, unanswered: [] });
const out = await h.runner.runTurn(TASK);
expect(out.runtime.items.find((i) => i.id === 'FREE')?.state).toBe('Completed');
});
});
/*
* 放行那一步不发工单(2026-08-27 W05 实测):模型在 T_release 先发了一张执行工单「评审放行与落账确认」,
* 人答完它才 submit,验收卡又来一张 —— 同一件事问人两遍。认法:剧本给它声明了人工验收清单且不是审核卡那一步。
*/
describe('终局放行那一步:human 被拒并指向 submit', () => {
const releaseStep = {
item: 'FREE',
intent: '提交放行',
guide: '做法',
writes: [],
reads: [],
tools: [],
view: 'x',
acceptanceChecklist: ['核对甲', '核对乙'],
};
const humanCard = {
kind: 'human',
itemId: 'FREE',
workOrder: 'execution',
card: { title: '评审放行与落账确认', ask: '请确认', basis: [{ k: '来源', v: '收口' }] },
};
it('对放行步发执行工单:行动被拒,拒回点名 submit;一张卡都不发', async () => {
const h = harness({ parsed: STAGE_MODEL, runtime: READY_RUNTIME2, action: humanCard, steps: [releaseStep] });
await h.runner.runTurn(TASK);
expect(h.issued).toEqual([]);
const rejected = h.appended.find((e) => e.kind === 'result' && e.title.startsWith('行动被拒'));
expect(rejected?.title).toContain('submit');
expect(rejected?.title).toContain('问人两遍');
});
it('审核卡那一步(cardVariant review)不算放行步 —— 照旧能发卡', async () => {
const h = harness({
parsed: STAGE_MODEL,
runtime: READY_RUNTIME2,
action: humanCard,
steps: [{ ...releaseStep, cardVariant: 'review' }],
});
await h.runner.runTurn(TASK);
expect(h.issued.length).toBe(1);
});
});
@@ -43,6 +43,23 @@ export function phaseProgress(
if (terminal) return { index: total, total, name: CLOSING_PHASE };
const settled = new Set(items.filter((i) => i.state === 'Completed' || i.state === 'Terminated').map((i) => i.id));
/*
* **被跳过的项不许把进度条钉死**(2026-08-27 用户实测:都在库时数据工单两项一直是 Available,
* 从前「一段里的项全收完才算过」让条永远停在 1/6,而单子早已办到审核了)。
* 现在先看「案子走到了哪一段」—— 最后一个有项**动过**(Active / Completed / Terminated)的阶段;
* 那一段全收完了就算过、进下一段,没收完就停在那一段。一段都没动过才退回老规矩(第一段)。
*/
const touched = new Set(
items.filter((i) => i.state === 'Active' || i.state === 'Completed' || i.state === 'Terminated').map((i) => i.id),
);
let last = -1;
for (const [at, phase] of phases.entries()) if (phase.items.some((id) => touched.has(id))) last = at;
if (last >= 0) {
const passed = phases[last]!.items.every((id) => settled.has(id));
const current = passed ? last + 1 : last;
const phase = phases[current];
return phase === undefined ? { index: total, total, name: CLOSING_PHASE } : { index: current + 1, total, name: phase.name };
}
for (const [at, phase] of phases.entries()) {
if (!phase.items.every((id) => settled.has(id))) return { index: at + 1, total, name: phase.name };
}
@@ -387,6 +387,15 @@ export function handsOff(actions: ReadonlyArray<{ kind: string }>): boolean {
const HANDOFF_KINDS = new Set(['human', 'decide', 'listen', 'submit']);
/**
* **这一步是不是终局放行那一步**(2026-08-27,W05 实测:模型在 T_release 先发了一张执行工单
* 「评审放行与落账确认」,人答完它才 submit —— 同一件事问了人两遍)。
* 认法:剧本给它声明了人工验收清单、又不是审核卡那一步 —— 放行卡由 `submit` 发,不该再有别的卡。
*/
export function isReleaseStep(step: { acceptanceChecklist?: ReadonlyArray<string> | undefined; cardVariant?: string | undefined } | undefined): boolean {
return step !== undefined && step.acceptanceChecklist !== undefined && step.acceptanceChecklist.length > 0 && step.cardVariant !== 'review';
}
export function sameGist(a: string, b: string): boolean {
const norm = (s: string): string => s.replace(/[\p{P}\p{S}\s]/gu, '');
const x = norm(a);
@@ -643,6 +652,11 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
* 熔断检测(stalledTurns 数「行动被拒」)靠它兜底,纯空转的单不能悄无声息。
*/
hasFollowUp = false,
/**
* **这一步派的卡穿哪副面孔**(剧本 `cardVariant`,2026-08-27):按行动里的项号查,项号没给 / 挂不上就按
* 当下在办的那一步 —— 从前只按项号查,模型漏了 itemId,审核卡就退成「需要你定」。
*/
faceOf?: (itemId: string | undefined) => { variant?: 'review'; checklist?: ReadonlyArray<string>; release?: true } | undefined,
): Promise<{
runtime: CaseRuntime;
status: TurnOutcome['status'];
@@ -650,6 +664,8 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
saved?: true;
/** 这个行动自己已经把画面摆好了 —— 回合末别再盖一帧上去 */
framed?: true;
/** 这个行动发了一张卡给人,卡是哪一步的 —— 回合末的画面跟着它走(见 runTurn 推帧那段) */
issuedFor?: string;
}> {
const context = await deps.tasks.of(taskId);
// 项号先归一(简写认得出),再往下走 —— 一处归一,所有行动型都受用
@@ -1118,6 +1134,17 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
return { runtime, status: 'running' };
}
}
/*
* **放行那一步不发工单**(2026-08-27 W05 实测):模型在 T_release 发了一张执行工单
* 「评审放行与落账确认」,人答完它才 submit,验收卡又来一张 —— 同一件事问人两遍。
* 放行卡由 submit 发(基座预检后发验收卡),这一步没有别的卡可发;拒回把路指清。
*/
if (faceOf?.(action.itemId)?.release === true) {
throw new Error(
'这一步是终局放行:放行卡由 submit 发(基座预检后自己发验收卡),不发执行工单 / 数据工单 —— ' +
'发了等于把同一件事问人两遍(先一张执行单、再一张验收单)。这一拍改成:judge 该裁的裁量判据,然后 submit。',
);
}
const { parsed: cardModel } = await loaded(taskId);
/*
* **发卡的授权面是调人预算,不是计划项**(2026-08-23,E04 回归实测撞出来的):
@@ -1161,6 +1188,7 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
return {
runtime: cardUnattached === undefined ? markWorldTouched(advanced, action.itemId) : advanced,
status: 'awaiting',
issuedFor: action.itemId,
};
}
@@ -1204,7 +1232,7 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
keyFilter: { cardId: entryCard.id },
deadline: 'P3D',
});
return { runtime: markWorldTouched(entryReady, action.itemId), status: 'awaiting' };
return { runtime: markWorldTouched(entryReady, action.itemId), status: 'awaiting', issuedFor: action.itemId };
}
case 'decide': {
@@ -1249,9 +1277,12 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
return { runtime, status: 'running' };
}
const advanced = readyToWork((await loaded(taskId)).parsed, runtime, action.itemId);
const face = faceOf?.(action.itemId);
const card = await deps.cards.decide({
taskId,
itemId: action.itemId,
...(face?.variant === undefined ? {} : { variant: face.variant }),
...(face?.checklist === undefined ? {} : { checklist: face.checklist }),
ownerId: context.ownerId,
humanCallCap: context.humanCallCap,
ask: action.ask,
@@ -1265,7 +1296,7 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
keyFilter: { cardId: card.id },
deadline: 'P3D',
});
return { runtime: markWorldTouched(advanced, action.itemId), status: 'awaiting' };
return { runtime: markWorldTouched(advanced, action.itemId), status: 'awaiting', issuedFor: action.itemId };
}
case 'listen': {
@@ -1514,13 +1545,20 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
detail: { internal: true },
});
}
/*
* 验收卡的题目用放行那一步在案例模型里的名字(如「评审放行与落账确认(ACT-GATE-03)」)——
* 「收口验收」是没声明时的兜底,人看见的应该是活动自己给这一步起的名。
*/
const { model: releaseModel, parsed: releaseParsed } = await loaded(taskId);
const gateStep = releaseModel.steps?.find((st) => isReleaseStep(st)) ?? releaseStep;
const releaseName = gateStep === undefined ? undefined : releaseParsed.items.find((i) => i.id === gateStep.item)?.name;
const card = await deps.cards.issue({
taskId,
ownerId: context.ownerId,
humanCallCap: context.humanCallCap,
workOrder: 'acceptance',
card: {
title: '收口验收',
title: releaseName === undefined || releaseName.trim() === '' ? '收口验收' : releaseName,
basis: [{ k: '提交理由', v: rationale }],
...(fields === undefined || fields.length === 0 ? {} : { fields: fields.map((f) => ({ ...f })) }),
},
@@ -1610,6 +1648,26 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
});
}
/*
* **叫了人就等人**(2026-08-27,用户:「你叫了人我都没填你就直接推进了」)。
*
* 这一单还有卡没交回,这一拍就不问模型 —— 不管是谁叫醒的:迟到的回传事件、另一台后端的
* 推进器、手动 wake。从前每一次唤醒都跑一拍,而事实行对模型说「这一步已经发过卡,人已经被
* 问过了」,它就照「人交回之后」那一段把步骤收掉 —— 没人答的卡就这样被跳过(实测:S1 的卡
* 发出 0.3 秒后,上一张卡回传的事件才投到,再一拍就「完成:评估输入与资料补齐确认」)。
* 在途验收(上面那段)先于它:验收裁决本身就是那张卡的回传。
*/
const waiting = await deps.cards.unanswered(taskId);
if (waiting.length > 0) {
await deps.memory.append({
taskId,
kind: 'observation',
title: `等人交回:${waiting.map((c) => c.title).join('、')} —— 这一拍不动`,
detail: { internal: true, cards: waiting.map((c) => c.id) },
});
return { runtime, enabled, navigation: { kind: 'converged' }, status: 'awaiting' };
}
/*
* 齐了即收:必做项完成且无进行中项 → 提交放行(发验收卡,终局唯放行)。
*
@@ -1772,22 +1830,37 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
* 只收 Active/Available 的项;已经收了的不重复收。收完重算 enabled(收掉一项
* 可能让下一项的前提成立),循环到收不动为止 —— 封顶 8 轮,防剧本写出环。
*/
let stateRuntime = runtime;
let stateEnabled = enabled;
if (steps.length > 0) {
/**
* 在场的项 = 当下可为 ∪ 进行中 —— 两摞都要看:
* 成果写完但还没 finish 的那一项正躺在「进行中」里,它才是最该被自动收的。
*
* **人工激活的项:人答过卡之后照常求 doneWhen**(2026-08-27 晚,用户:「根因修了吗?不要只修表象」)。
* 从前一律不收 —— 怕引擎替人签字(T_release 触达世界,收它得人按一下)。可 W05 的两张数据工单实测:
* 卡人答了、数据也入库了,「这一步办完了没有」是引擎手里现成的事实,却让模型去记得 finish ——
* 剧本两处白纸黑字「先 finish 再发下一张」,它照样忘,两步同时 Active、件停在上一步、人无处可填。
* 设计律:**指南让模型判的事,引擎有现成事实的,一律改成读事实。**
* 门槛仍在:`worldTouched`(卡真发过)—— 卡没发过的人工激活项 doneWhen 再真也不收,那才是替人签字;
* 走到这儿时没有未答的卡(前面 `cards.unanswered` 把住),拍尾那一遍另把这一拍刚发卡的项排除。
* 没声明 doneWhen 的人工激活项(T_release / 审核循环 / 交人补缺口)一个字不变。
*/
const sweepRipe = async (
runtimeIn: CaseRuntime,
enabledIn: ReturnType<typeof publishEnabled>,
scopeIn: typeof scope,
exclude?: string,
): Promise<{ runtime: CaseRuntime; enabled: ReturnType<typeof publishEnabled> }> => {
let runtimeNow = runtimeIn;
let enabledNow = enabledIn;
if (steps.length === 0) return { runtime: runtimeNow, enabled: enabledNow };
for (let round = 0; round < 8; round += 1) {
/*
* 在场的项 = 当下可为 ∪ 进行中 —— 两摞都要看:
* 成果写完但还没 finish 的那一项正躺在「进行中」里,它才是最该被自动收的。
* **人工激活的项不自动收**(T_release 触达世界,收它得人按一下)。
*/
const onStage = [...stateEnabled.entries, ...stateEnabled.inProgress];
const onStage = [...enabledNow.entries, ...enabledNow.inProgress];
const ripe = onStage.find((e) => {
if (e.requiresManualStart) return false;
if (e.id === exclude) return false;
if (e.requiresManualStart && runtimeNow.items.find((i) => i.id === e.id)?.worldTouched !== true) return false;
const step = stepOf(e.id);
if (step?.doneWhen === undefined || step.doneWhen.trim() === '') return false;
try {
return deps.sfeel.condition(step.doneWhen, scope);
return deps.sfeel.condition(step.doneWhen, scopeIn);
} catch {
// 谓词写坏了不该拖垮整拍:当作「没算出来」,照旧交给模型
return false;
@@ -1796,8 +1869,8 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
if (ripe === undefined) break;
const step = stepOf(ripe.id);
try {
const active = readyToWork(parsed, stateRuntime, ripe.id);
stateRuntime = transitionItem(active, ripe.id, 'Completed');
const active = readyToWork(parsed, runtimeNow, ripe.id);
runtimeNow = transitionItem(active, ripe.id, 'Completed');
} catch {
break;
}
@@ -1807,11 +1880,13 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
title: `自动收步:${ripe.id} 的成果已在台账里(${step?.doneWhen ?? ''})—— 不必再问模型`,
detail: { internal: true, item: ripe.id, doneWhen: step?.doneWhen },
});
stateEnabled = publishEnabled(parsed, stateRuntime, scope, changedRefs(scope), deps.sfeel.condition, [
...stateEnabled.gaps,
]);
enabledNow = publishEnabled(parsed, runtimeNow, scopeIn, changedRefs(scopeIn), deps.sfeel.condition, [...enabledNow.gaps]);
}
}
return { runtime: runtimeNow, enabled: enabledNow };
};
const swept = await sweepRipe(runtime, enabled, scope);
const stateRuntime = swept.runtime;
const stateEnabled = swept.enabled;
/*
* 当前这一步 = 当下可为里**第一个有剧本的项**(CMMN 的项序即业务次序)。
@@ -1850,6 +1925,17 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
const declared = parsed.items.find((i) => i.id === current.item);
if (declared?.manualActivation !== true) return '';
const touched = stateRuntime.items.find((i) => i.id === current.item)?.worldTouched === true;
/*
* 终局放行那一步的事实行**只指向 submit**(2026-08-27 W05 实测):下面那句「唯一合法的动作是先发
* 那一张卡」本是给人工激活的执行 / 数据 / 决策步写的,放行步读到它就去发了一张执行工单。
*/
if (isReleaseStep(current)) {
return touched
? '这一步是终局放行:验收卡已经发出去了,人签字之前不要再动台账、不要再发任何卡;被打回重做照剧本改完再 submit。'
: '这一步是终局放行(触达世界):**唯一合法的动作是 submit(提交放行)** —— 基座预检后自己发验收卡。' +
'不要发执行工单 / 数据工单 / 决策卡,也不要立监听(发了会被拒,而且等于把同一件事问人两遍)。' +
'先 judge 该裁的裁量判据,再 submit,同一拍连做。';
}
return touched
? '这一步标了人工激活(触达世界):这一单里已经真的给它发过卡或立过监听——人已经被问过了,' +
'照下面「人交回之后」那一段办。'
@@ -2058,6 +2144,8 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
let saved = false;
let framed = false;
let lastAction: AgentAction | undefined;
/** 这一拍发出去的卡是哪一步的 —— 画面跟卡走 */
let issuedFor: string | undefined;
/*
* **finish 的 doneWhen 要按「此刻」的草稿算,不是拍首快照**(2026-08-22,E04 实测):
* 「同一串先写后 finish 一拍连做」是提示词鼓励的省拍打法,而 doneWhen 若按拍首的
@@ -2077,6 +2165,7 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
reason?: string;
saved?: true;
framed?: true;
issuedFor?: string;
};
try {
// AI 不得产生终局迁移 —— start 一个不存在的项按被拒处理,不掀翻整拍
@@ -2103,6 +2192,20 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
}
},
index < planned.length - 1,
// 面孔与人工验收清单按剧本定:行动点名的项声明了就用它的,没声明就按当下在办的那一步
(itemId) => {
const named = itemId === undefined ? undefined : stepOf(itemId);
const step = named?.cardVariant !== undefined || named?.acceptanceChecklist !== undefined ? named : (current ?? named);
const variant = step?.cardVariant === 'review' ? ('review' as const) : undefined;
const checklist = step?.acceptanceChecklist;
const release = isReleaseStep(step);
if (variant === undefined && checklist === undefined && !release) return undefined;
return {
...(variant === undefined ? {} : { variant }),
...(checklist === undefined ? {} : { checklist }),
...(release ? { release: true as const } : {}),
};
},
);
if (action.kind === 'write' || action.kind === 'drop') scopeStale = true;
} catch (error) {
@@ -2120,6 +2223,7 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
if (applied.reason !== undefined) blockedReason = applied.reason;
if (applied.saved === true) saved = true;
if (applied.framed === true) framed = true;
if (applied.issuedFor !== undefined) issuedFor = applied.issuedFor;
// 挂起型落地即收拍:卡已发 / 监听已立 / 验收已提 —— 这一拍到此为止
if (applied.status !== 'running') break;
// 工具出参要先看见:同拍余下的行动是闭着眼计划的,作废并留痕
@@ -2134,6 +2238,21 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
}
}
/*
* **拍尾再扫一遍**(2026-08-27 晚):人答卡的那一拍里模型写完东西,上一步的 doneWhen 往往这时才真
* (值是人在件上填的、旗是这一拍记的);不等下一拍 —— 下一拍可能要等人再答一张卡才来,
* 而画面与进度条在这之间都得对(实测:P-08 没收、P-10 的卡发了,人对着 P-08 的件无处可填)。
* 提交放行那一拍(saved)不扫:它已连在途验收一起落盘,再存一次会把信封擦掉。
* 这一拍刚发卡的项排除(卡还没人答,收它就是替人签字)。
*/
if (!saved) {
if (scopeStale) {
liveScope = await deps.data.view(taskId);
scopeStale = false;
}
const enabledTail = publishEnabled(parsed, currentRuntime, liveScope, changedRefs(liveScope), deps.sfeel.condition, [...stateEnabled.gaps]);
currentRuntime = (await sweepRipe(currentRuntime, enabledTail, liveScope, issuedFor)).runtime;
}
// 已经自己落过笔的行动(提交放行连在途验收一起存)不再兜底保存 —— 见 submit 分支的注释
if (!saved) await deps.state.save(taskId, model, parsed, currentRuntime);
@@ -2181,11 +2300,22 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
.filter((e) => !settledNow.has(e.id))
.map((e) => stepOf(e.id))
.find((x) => x !== undefined);
const wantedNow = stepNow?.view !== undefined && stepNow.view !== '' ? stepNow.view : wanted;
/*
* **画面跟着这一拍发出去的卡走**(2026-08-27 晚,用户送来 3ef3fc3e:「当前这一步不是应该给我推
* P-10 的数据工单吗?怎么还是推送的芯片主数据创建,这样我都没法填数据了」)。
* 上面那条「按办完之后在办的那一步」在**两步同时 Active** 时仍会挑错:模型没收掉 S0d_p08(剧本白纸黑字
* 写了「先 finish 再发下一张」,两处;措辞治不了),直接发了 S0d_p10 的数据工单 —— 「第一步没干完的」
* 算出来还是 P-08,人拿着 P-10 的卡对着 P-08 的件,提交闸拦「缺资料」而屏上没有资料表可填。
* 卡是发给人的那件事,件必须是那件事的件:这一拍发了哪一步的卡(human / entry / decide),
* 那一步声明了画面就推它,压过别的规则;没发卡的拍照旧。
*/
const issuedStep = issuedFor === undefined ? undefined : stepOf(issuedFor);
const issuedView = issuedStep?.view !== undefined && issuedStep.view !== '' ? issuedStep.view : undefined;
const wantedNow = issuedView ?? (stepNow?.view !== undefined && stepNow.view !== '' ? stepNow.view : wanted);
const frame = wantedNow !== undefined && wantedNow !== '' ? wantedNow : steps.length === 0 ? 'case-overview' : undefined;
if (!framed && frame !== undefined && frame !== '') {
try {
const shown = await showView(taskId, frame, currentRuntime, stepNow?.view ?? liveStep?.view);
const shown = await showView(taskId, frame, currentRuntime, issuedView ?? stepNow?.view ?? liveStep?.view);
if (!shown.pushed) {
await deps.memory.append({
taskId,
+14
View File
@@ -210,7 +210,21 @@ export type CardGateway = {
allowCustom?: boolean;
/** 这张卡挂在哪个计划项上 —— 组装层按它查剧本声明(`cardVariant: 'review'` = 摆成验收工单) */
itemId?: string | undefined;
/**
* **这张卡穿哪副面孔,引擎已经按剧本定好**(2026-08-27):模型没给 itemId、或给的项挂不上时,
* 组装层按 itemId 查剧本就查不到,卡就退成「需要你定」—— 而这一步的剧本明明写着 `cardVariant: 'review'`。
* 引擎知道当下在办哪一步,由它把面孔定下来递过去;组装层只在这里没给时才自己按 itemId 查。
*/
variant?: 'review' | undefined;
/** 人工验收清单(剧本 `acceptanceChecklist`)—— 审核卡上一条一枚「达标 / 不达标」开关 */
checklist?: ReadonlyArray<string> | undefined;
}): Promise<{ id: string }>;
/**
* **这一单还有哪几张卡在等人交回**(2026-08-27,用户:「你叫了人我都没填你就直接推进了」)。
* 每拍开头问一次:有卡没答,这一拍不问模型 —— 叫了人就等人;
* 任何多余的唤醒(迟到的回传事件、另一台后端的推进器)都推不动它。
*/
unanswered(taskId: TaskId): Promise<ReadonlyArray<{ id: string; title: string }>>;
};
export type ListenerRegistrar = {
+3
View File
@@ -20,6 +20,9 @@
event(L1,输出口发射)、memory(L2)
被依赖: loop-engine, flow-engine, task, app
近变(2026-08-27):`SubmitForReleaseInput.checklist` / `CardGateway.issueAcceptance.checklist`(可选)——
人工验收清单原样带上验收卡;放行门本身不看它(它把的是「人核了没有」,由 human-tool 的回传校验拦)。
不变量(OS 7.3 / AM 12.1):
- **预检先于放行**:`submitForRelease` 预检不过即抛 `PrecheckFailedError`,**不发卡**
- **卡面即已验证的动作清单**:验收工单的 actions 只列预检通过的意向
@@ -48,6 +48,7 @@ function harness(over: { conflicts?: Array<{ field: string; key: Record<string,
mergedRows: 2,
emittedEvents: 2,
simulatedRows: 0,
landedEarlierRows: 0,
merged: [{ domain: 'd', object: 'o', key: { pn: 'X1' }, values: { type_tag: 'A' } }],
}),
},
+2
View File
@@ -48,6 +48,8 @@ export type SubmitForReleaseInput = {
readonly basis: ReadonlyArray<{ k: string; v: string }>;
readonly deliverables: readonly DeliverableCheck[];
readonly criteria: readonly CriterionCheck[];
/** 人工验收清单(剧本声明,固定):卡上一条一枚开关,全部达标才能通过 */
readonly checklist?: ReadonlyArray<string>;
/** 报告可修订:AI 拟值已填的表单字段 */
readonly fields?: ReadonlyArray<Record<string, unknown>>;
/**
@@ -142,6 +142,8 @@ export function createReleaseService(deps: ReleaseServiceDeps): ReleaseService {
})),
// 卡面呈现的即**已验证**的动作清单
actions: report.verified.map((v) => ({ label: v.label, effect: v.effect, detail: v.detail })),
// 人工验收清单:人核这份工单要走的步骤(剧本声明,固定)
...(input.checklist === undefined || input.checklist.length === 0 ? {} : { checklist: input.checklist }),
/*
* 可修订字段 —— 验收报告「AI 已填好、可编辑」改的就是它们。
*
@@ -185,12 +187,14 @@ export function createReleaseService(deps: ReleaseServiceDeps): ReleaseService {
kind: 'ledger-write',
// 试跑挡下的那几行要写在痕上 —— 只报「合并 N 行」而其中几行没进表,那句话是假的
title:
merged.simulatedRows === 0
? `放行写合并 ${merged.mergedRows} 行`
: `放行写合并 ${merged.mergedRows} 行(试跑:另有 ${merged.simulatedRows} 行主数据只留在活动缓存,未落正式表)`,
`放行写合并 ${merged.mergedRows} 行` +
(merged.simulatedRows === 0 ? '' : `(试跑:另有 ${merged.simulatedRows} 行主数据只留在活动缓存,未落正式表)`) +
// 数据工单确认那一刻已入库的行不算在「合并」里 —— 那句话只报这一签落下的
(merged.landedEarlierRows === 0 ? '' : `(另有 ${merged.landedEarlierRows} 行已在数据工单确认时入库)`),
detail: {
rows: merged.mergedRows,
simulated: merged.simulatedRows,
landedEarlier: merged.landedEarlierRows,
tables: [...byTable].map(([address, rows]) => ({ address, rows })),
},
});
+4
View File
@@ -21,6 +21,8 @@ export type LedgerGateway = {
emittedEvents: number;
/** 试跑单被挡在正式表外的主数据行数(非试跑恒为 0)—— 放行痕要如实说 */
simulatedRows: number;
/** 早已在数据工单确认时入库、这次不再写的行数 —— 放行痕上分开说,「合并 N 行」不多报 */
landedEarlierRows: number;
/** 合并进正式版的行 —— 输出口发射的载荷取自这里 */
merged: ReadonlyArray<{
domain: string;
@@ -76,6 +78,8 @@ export type CardGateway = {
rationale?: string | undefined;
}>;
actions: ReadonlyArray<{ label: string; effect: 'read' | 'write'; detail: string }>;
/** 人工验收清单 —— 原样带上卡 */
checklist?: ReadonlyArray<string> | undefined;
/**
* 可修订字段 —— 验收报告「AI 已填好、可编辑」改的就是它们(带 `ai` 拟值与 `ledger` 落回地址)。
* **端口不声明它,值就在装配层被丢掉**:上游一路传下来,而 TypeScript 对条件展开
+7
View File
@@ -44,6 +44,13 @@
任一项触顶即 `exhausted`,空转即 `stalled`
- **监听中是健康态**:有监听在等或等人回传一律**不算停滞**
- **终态五名封闭**:`decideTerminal` 是唯一命名口;`released` 是唯一成功终态
- **迟到的人事件不再推一拍**(2026-08-27):`deliver` 的人事件带 `occurredAt`,任务在这条回传之后已推进过
(`lastProgressAt > occurredAt`)就只记一条到达痕(`detail.stale = true`),不 `onEvent`、不 `tick` ——
回传落库那一刻回传口已 `wake`,推进器读留痕里的 card-reply 先跑了那一拍;事件晚十几秒才到,再推一拍
就是让模型对着一张没人答的新卡「接着办」(实测把 S1 收掉)。没带 `occurredAt` 的老调用方行为照旧
**放行裁决(`payload.mode = 'acceptance'`)不在此列**:它只走事件这一条路(loop 的 `onEvent` 把 verdict
落进在途验收信封,拍里读的是信封,留痕 / 卡库里读不到它),迟到也必须投 —— 否则签字之后
`wake` → 推进器抢先一拍挪了 `lastProgressAt`,事件到时被判 stale,单子永远停在「字已签下,正在落地」
- **已终结不得再推进**:`tick` / `deliver` 对终态任务抛 `TaskAlreadyTerminalError` ——
流级重做一律重开新实例,不复活旧任务
- **叫停不是终态**:转挂起待纠偏,并记**最重档纠正信号**
@@ -0,0 +1,119 @@
/**
* 迟到的人事件不再推一拍(2026-08-27,用户:「你叫了人我都没填你就直接推进了」)。
*
* 回传落库那一刻回传口已 wake、推进器随即跑了那一拍(读留痕里的 card-reply,不等事件);
* 人事件从队列里投过来晚十几秒,那时上一拍早已发出下一张卡 —— 再 tick 一拍,模型会把没人答的
* 那一步收掉。判法只有一个数:这一单在这条回传之后推进过没有(lastProgressAt > occurredAt)。
*/
import { describe, expect, it } from 'vitest';
import type { StaffId, 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';
function harness(lastProgressAt: Date | undefined) {
const task: Task = {
id: 't-1' as TaskId,
activityId: 'w05',
ownerId: 's-1' as StaffId,
title: 'x',
runtime: 'awaiting-human',
envelope: {},
data: {},
caps: { steps: 40, tokens: 200000, humanCalls: 4, activeMinutes: 60 },
used: { steps: 0, tokens: 0, humanCalls: 0, activeMs: 0 },
startedAt: '2026-08-27T09:40:00.000Z',
};
const tasks = new Map<string, Task>([[task.id, task]]);
const appended: Array<{ kind: string; title: string; detail?: Record<string, unknown> }> = [];
let advanced = 0;
let onEvents = 0;
const deps: TaskServiceDeps = {
store: {
get: async (id) => tasks.get(id),
insert: async (t) => void 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 () => lastProgressAt,
markProgress: async () => undefined,
listByOwner: async () => [],
},
activities: { load: async () => ({ caps: task.caps, envelope: {}, inputSchema: {}, businessModel: {}, stallAfter: 'PT30M' }) },
schemas: { validate: () => ({ ok: true, errors: [] }) },
engine: {
load: async () => undefined,
advance: async () => {
advanced += 1;
return { status: 'awaiting' };
},
onEvent: async () => {
onEvents += 1;
return { status: 'running' };
},
},
memory: {
append: async (entry) => void appended.push({ kind: entry.kind, title: entry.title, ...(entry.detail === undefined ? {} : { detail: entry.detail }) }),
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 () => undefined },
comms: { record: async () => undefined },
clock: { now: () => new Date('2026-08-27T09:48:30.000Z') },
ids: { next: () => 't-x' },
};
return { service: createTaskService(deps), appended, advanced: () => advanced, onEvents: () => onEvents };
}
const REPLY_AT = '2026-08-27T09:47:48.000Z';
describe('迟到的人事件', () => {
it('回传之后这一单已推进过 → 只记一条到达痕(标 stale),不 onEvent、不推一拍', async () => {
const h = harness(new Date('2026-08-27T09:48:00.200Z'));
const out = await h.service.deliver('t-1' as TaskId, { kind: 'human', ref: 'card-p10', payload: {}, occurredAt: REPLY_AT });
expect(out.progressed).toBe(false);
expect(h.advanced()).toBe(0);
expect(h.onEvents()).toBe(0);
const arrival = h.appended.find((e) => e.title === '事件到达(human):card-p10');
expect(arrival?.detail?.['stale']).toBe(true);
});
it('回传之后没推进过 → 照常投递并推一拍(这就是叫醒它的那一下)', async () => {
const h = harness(new Date('2026-08-27T09:47:40.000Z'));
await h.service.deliver('t-1' as TaskId, { kind: 'human', ref: 'card-s1', payload: {}, occurredAt: REPLY_AT });
expect(h.onEvents()).toBe(1);
expect(h.advanced()).toBe(1);
});
it('放行裁决即使迟到也要投:它只走事件这一条路,拍里读不到它', async () => {
const h = harness(new Date('2026-08-27T09:48:00.000Z'));
const out = await h.service.deliver('t-1' as TaskId, {
kind: 'human',
ref: 'card-acc',
payload: { mode: 'acceptance', verdict: 'pass', values: {} },
occurredAt: REPLY_AT,
});
expect(h.onEvents()).toBe(1);
expect(h.advanced()).toBe(1);
expect(out.progressed).toBe(true);
const arrival = h.appended.find((e) => e.kind === 'observation' && e.title.startsWith('事件到达'));
expect(arrival?.detail?.['stale']).toBeUndefined();
});
it('没带 occurredAt(老调用方)/ 不是人事件 → 行为照旧', async () => {
const h = harness(new Date('2026-08-27T09:48:00.200Z'));
await h.service.deliver('t-1' as TaskId, { kind: 'human', ref: 'card-x', payload: {} });
await h.service.deliver('t-1' as TaskId, { kind: 'data', ref: 'row-1', payload: {}, occurredAt: REPLY_AT });
expect(h.advanced()).toBe(2);
});
});
+8 -1
View File
@@ -98,7 +98,14 @@ export type TaskService = {
/** 推进一拍:**预算在环外先扣先判**,再交范式引擎 */
tick(taskId: TaskId): Promise<TickOutcome>;
/** 事件驱动续跑 —— 挂起期间事件不丢,恢复即补投 */
deliver(taskId: TaskId, event: { kind: 'data' | 'time' | 'human' | 'control'; ref: string; payload: Record<string, unknown> }): Promise<TickOutcome>;
/**
* 投递一条事件并推一拍。`occurredAt` = 事件发生时刻(信封的 occurredAt):人事件要拿它与任务的
* 最近推进时刻比 —— 回传那一刻已经 wake、推进器抢先跑了那一拍的,这条事件只是迟到的回声,不再推。
*/
deliver(
taskId: TaskId,
event: { kind: 'data' | 'time' | 'human' | 'control'; ref: string; payload: Record<string, unknown>; occurredAt?: string },
): Promise<TickOutcome>;
/** 控制三语义:暂停 / 叫停 / 终止 */
control(taskId: TaskId, semantic: ControlSemantic, by: StaffId, reason?: string): Promise<ControlOutcome>;
@@ -310,6 +310,35 @@ export function createTaskService(deps: TaskServiceDeps): TaskService {
* 从前 loop 的 onEvent 还会再记一条「事件到达(kind):ref」—— 同一次回传在日志里
* 三行几乎一样(卡回传 + 两层各一条到达),人看着像系统在结巴。
*/
/*
* **迟到的人事件不再推一拍**(2026-08-27,用户:「你叫了人我都没填你就直接推进了」)。
*
* 回传落库那一刻,回传口已经 `wake` 了这一单,推进器随即跑了那一拍(它读的是留痕里的
* card-reply,不等事件);人事件从队列里投过来往往晚十几秒 —— 那时上一拍早已发出了
* **下一张卡**。从前这里照样 `tick`:又一拍模型,而引擎的事实行对它说「这一步已经发过卡,
* 人已经被问过了」,模型就把没人答的那一步收掉了(实测:S1 的卡发出 0.3 秒后,P-10 那张
* 回传的事件才到,再一拍就「完成:评估输入与资料补齐确认」)。
* 判法只有一个数:**这一单在这条回传之后推进过没有**(`lastProgressAt > occurredAt`)——
* 推进过,回传一定已被那一拍消费(留痕先于事件落);没推进过,这条事件就是叫醒它的那一下。
*/
/*
* **验收裁决不在此列**(2026-08-27 晚,用户:「这一单怎么卡在字已签下,正在落地,没有完成」):
* 上面那条「推进过 = 已消费」的前提是**那一拍能从留痕 / 卡库里读到这份答复**(执行单的填报、
* 决策卡的选项都如此);而放行裁决只走事件这一条路 —— loop 的 onEvent 把 verdict 落进在途验收的
* 信封,拍里读的是信封。签字落库 → wake → 推进器抢先跑了一拍(在途验收没裁决,原地返回,
* 但 lastProgressAt 已经往前挪)→ 事件晚 1.6 秒到 → 被判 stale 丢掉:单子永远停在「字已签下,正在落地」。
* 认结构化键 `mode`(回传口随 reply 一并入事件),不认时刻。
*/
const verdictReply = event.payload['mode'] === 'acceptance';
const stale =
event.kind === 'human' &&
!verdictReply &&
event.occurredAt !== undefined &&
(await (async () => {
const last = await deps.store.lastProgressAt(taskId);
const at = new Date(event.occurredAt ?? '').getTime();
return last !== undefined && !Number.isNaN(at) && last.getTime() > at;
})());
await deps.memory.append({
taskId,
kind: 'observation',
@@ -318,8 +347,12 @@ export function createTaskService(deps: TaskServiceDeps): TaskService {
* `kind` 要进 detail:对话区与工作日志都靠它认这是哪一类到达。
* 从前只有 payload,两处只能去抠标题前缀 —— 标题是给人看的、随时会改。
*/
detail: { ...event.payload, kind: event.kind },
detail: { ...event.payload, kind: event.kind, ...(stale ? { stale: true, note: '这条回传在到达前已被推进过的那一拍处理,这次不再推' } : {}) },
});
if (stale) {
const held = await require(taskId);
return { task: held, verdict: { kind: 'ok' }, progressed: false };
}
/*
* 受理补全的答复(宽进):任务还在「等人」且谱一步没走 —— 这时候来的 human 事件