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
+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 = {