E04 修到零被拒线,harness 补七刀:跨进程租约与广播桥、原子写、引用墙、对话区按序号排

活动侧(E04 2.1.0 → 2.1.9,I01 1.2.2):
- 覆盖闭合成为机检:基座新增 distinct 读数,「计划盖住的客户数 = 候选行数」钉进
  S3 收步 / S4 哨兵 / 放行 L2;人加的候选客户不再被整单无视
- 「关键人待识别」占位句式全链退役:查无联系人先发数据录入卡收人;契约新增 refersTo,
  计划/回执的 contact_code 只许引用真实存在的联系人(安装期核引用目标)
- 台账更新确认摆全落库面:客户档案/联系人档案两张落库预览(touched: 数据源,
  正式现值 + 草稿叠加),可补改、可加行(自增主键由基座续号);姓名升必填
- 更新明细:update_key 位置键 + 「同一处只留一条」L2;依据写人话不带机器键;
  标题旁不摆单号;判据与文案不夹字段名;行标识去掉单标识那一段
- 回执骨架 = 计划行数钉进 S3 收步与 S4 哨兵;重做分支不许删计划凑数
- 阶段刻度对齐文档 7 步;起单样例预填满

基座侧:
- 两个后端连同一个库:task-claim 跨进程租约(迁移 0030)+ Postgres LISTEN/NOTIFY
  广播桥;对话区按任务内记忆序号排,不按墙上时钟;并卡「刚说过话」按记忆序列判紧邻
- 多行 write 整批先验后写(dryRun),被拒 = 一行都没落
- 决策卡意见与本单草稿字面比对,归属以观察进轨迹(note-anchors)
- 括号栈补「补闭括号」两形(少一个 } / 行对象没关直接跟下一行)
- 件上加行:空着的自增主键直进续号,不从别的行借号;未落下的行点名回给前端
- 人答复落库那一刻就把 runtime 翻 running(task.wake),两块同帧变脸
- 试跑单可落真库(land_master,迁移 0031,默认开);数据域按活动分组;
  JSON 列不再是 [object Object];按钮键感 + 在办进度条 + 防抖
- 报价链 A1–A7 从系统卸载(只卸不删源码),种子只装 E04/I01

契约变更(api-report 已刷新):FieldDeclaration.distinct / refersTo,
objectDeclaration.file,landMaster,unkeyed,assetFiles,ledgerByActivityRoute。
1700 测 / typecheck / lint / depcruise / api:check 绿。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EEJX8Z7CHejvwmDQyPQuBX
This commit is contained in:
xcs
2026-08-25 01:23:30 +08:00
co-authored by Claude Fable 5
parent 92dd729c36
commit f3397a60ef
91 changed files with 3919 additions and 350 deletions
@@ -107,6 +107,15 @@ describe('判据谓词的名字必须绑得上', () => {
expect(codes(manifest({ predicate: 'disposition = "照原价报"', fields: ['disposition'] }))).toEqual([]);
});
it('`distinct` 声明出的名字也绑得上 —— 覆盖闭合判据的落点(2026-08-24)', () => {
const bound = manifest({ predicate: 'd_record_distinct_customer_code = d_record_rows', fields: ['customer_code'] });
(bound.declarations.ledger[0]!.fields[0] as { distinct?: boolean }).distinct = true;
expect(codes(bound)).toEqual([]);
// 没标 distinct,这个名字就不存在 —— 拒装,别让判据指着一个永远为假的读数
const unbound = manifest({ predicate: 'd_record_distinct_customer_code = d_record_rows', fields: ['customer_code'] });
expect(codes(unbound)).toEqual(['acceptance-predicate-unbound']);
});
it('L3 不机检 —— 没谓词也不报', () => {
expect(codes(manifest({ level: 'L3', predicate: undefined }))).toEqual([]);
});
@@ -295,3 +304,47 @@ describe('工具调用读数绑得上', () => {
expect(withTool('tool_erp_write_calls > 0').length).toBeGreaterThan(0);
});
});
/* ── refersTo 的引用目标必须声明过(2026-08-24)──────────────────────── */
import { referenceFindings } from '../internal/domain/static-audit.ts';
const REF_LEDGER = (refersTo: string) => ({
declarations: {
ledger: [
{
domain: 'crm_coverage',
object: 'plan',
kind: 'record' as const,
caseKey: 'round_ref',
label: '计划',
fields: [
{ name: 'round_ref', type: 'text' as const, primaryKey: true, masked: false },
{ name: 'contact_code', type: 'text' as const, primaryKey: true, masked: false, refersTo },
],
},
{
domain: 'customer',
object: 'contact',
kind: 'master' as const,
label: '联系人',
fields: [{ name: 'contact_code', type: 'text' as const, primaryKey: true, masked: false }],
},
],
},
});
describe('refersTo 目标要在本活动的声明里 —— 写错的地址会变成永远查无此值的死墙', () => {
it('指向声明过的对象与列 —— 放行', () => {
expect(referenceFindings(REF_LEDGER('customer.contact.contact_code'))).toEqual([]);
});
it('表名写错 —— 拒装', () => {
const found = referenceFindings(REF_LEDGER('customer.contacts.contact_code'));
expect(found.map((f) => f.code)).toEqual(['reference-target-unbound']);
});
it('列名写错 —— 拒装', () => {
const found = referenceFindings(REF_LEDGER('customer.contact.code'));
expect(found.map((f) => f.code)).toEqual(['reference-target-unbound']);
});
});
@@ -290,6 +290,12 @@ function boundNames(manifest: AcceptanceAuditInput): ReadonlySet<string> {
const slug = value.toLowerCase().replace(/[^a-z0-9_]/g, '');
if (slug !== '') out.add(`${table.domain}_${table.object}_${field.name}_${slug}`);
}
/*
* 不同值计数读数 `域_对象_distinct_<列>`(2026-08-24 起)——
* 「每家候选客户都进了计划」这类**覆盖闭合**判据的落点:
* 计划行数 ≠ 客户数,闭合要成为机检就得有「有几家不同客户」这个基座数。
*/
if (field.distinct === true) out.add(`${table.domain}_${table.object}_distinct_${field.name}`);
}
}
/*
@@ -413,6 +419,7 @@ export function staticAudit(manifest: ActivityManifest, external: ExternalChecks
out.push(...danglingListeners(manifest));
out.push(...deliverableAddressFindings(manifest));
out.push(...referenceFindings(manifest));
out.push(...ledgerKindFindings(manifest));
out.push(...acceptanceLevelFindings(manifest));
out.push(...loopFindings(manifest));
@@ -420,6 +427,36 @@ export function staticAudit(manifest: ActivityManifest, external: ExternalChecks
return out;
}
/**
* `refersTo` 的引用目标必须是**本活动声明过的对象与列**(2026-08-24 立)。
*
* 引用检查在写入那一刻执行 —— 目标地址写错(表名手滑、列名不存在)的话,
* 那道墙就成了「永远查无此值」的死墙,活动装上就跑不动。与判据谓词绑定同一个道理:
* 这种错只有跑到写入那一刻才炸,必须拦在安装期。
*/
export function referenceFindings(manifest: LedgerAuditInput): StaticAuditFinding[] {
const out: StaticAuditFinding[] = [];
for (const table of manifest.declarations.ledger) {
for (const field of table.fields) {
if (field.refersTo === undefined) continue;
const [rd, ro, rc] = field.refersTo.split('.');
const referenced = manifest.declarations.ledger.find((l) => l.domain === rd && l.object === ro);
const column = referenced?.fields.find((f) => f.name === rc);
if (referenced !== undefined && column !== undefined) continue;
out.push({
code: 'reference-target-unbound',
severity: 'error',
message:
`${table.domain}.${table.object}.${field.name} 的 refersTo 指向 \`${field.refersTo}\`,` +
`而本活动的台账声明里${referenced === undefined ? '没有这张表' : '这张表没有这一列'} —— ` +
'引用检查会变成「永远查无此值」的死墙,装上就跑不动。改成声明里真实存在的 域.对象.列。',
at: `declarations.ledger ${table.domain}.${table.object}.${field.name}`,
});
}
}
return out;
}
export function auditPassed(findings: readonly StaticAuditFinding[]): boolean {
return !findings.some((f) => f.severity === 'error');
}
@@ -138,6 +138,39 @@ describe('同一轮的交代与卡并成一条', () => {
expect(out).toHaveLength(3);
});
it('**隔着一张决策卡(另一路合并、不进对话项)也不并** —— 上一步的话不许认领这一步的卡(2026-08-25 实测)', () => {
// I01 实测形:说「请审核」→ 发决策卡 → 人答 → 收口 → 发验收卡;验收卡曾被挂到「请审核」上,排到决策卡前面
const out = projectChat([
say('n', '两份内容初稿已草拟完成,请审核'),
entry('card-sent', { id: 'd', title: '内容初稿已草拟,共 2 份,请审核。', detail: { k: 'decision', kind: 'chat-card', id: 'dc-1' } }),
entry('card-reply', { id: 'r', title: '内容初稿已草拟,共 2 份,请审核。', detail: { value: 'approve' } }),
entry('observation', { id: 'o', title: '事件到达(human):dc-1', detail: { kind: 'human' } }),
card('c', '收口验收', 'wo-7'),
]);
const ai = out.filter((m) => m.k === 'ai');
expect(ai).toHaveLength(2);
expect(ai[0]?.k === 'ai' ? ai[0].ref : 'x').toBeUndefined(); // 「请审核」那句话不带工单
expect(ai[1]?.k === 'ai' ? ai[1].ref : undefined).toBe('wo-7'); // 验收卡自成一条,排在后面
});
it('隔着人的答复 / 内部观察也不并 —— 「刚说过」按记忆序列判,不按对话项判', () => {
const out = projectChat([
say('n', '我去查一下'),
entry('observation', { id: 'o', title: '这一拍在往下办事', detail: { internal: true } }),
card('c', '受理需求确认', 'wo-8'),
]);
expect(out.filter((m) => m.k === 'ai')).toHaveLength(2);
});
it('装配戳夹在中间不算隔开 —— 它是信封件,不是一拍', () => {
const out = projectChat([
say('n', '请核对'),
entry('thought', { id: 't', title: '装配技能:x@1.0.0' }),
card('c', '受理需求确认', 'wo-9'),
]);
expect(out).toHaveLength(1);
});
it('收尾那条结论不认领卡 —— 它是这一单的句号,不是派工', () => {
// 用**没顺利收尾**的那种:released 起同日不再出声(见上一组)
const out = projectChat([entry('terminal', { id: 't', title: '终态:blocked' }), card('c', '卡', 'wo-5')]);
@@ -70,13 +70,25 @@ export function projectChat(entries: readonly NarrativeEntry[]): ChatMessage[] {
* 验收卡又自成一条 —— 第一轮并对了、第二轮没并,同一屏两种样子。
*/
let lastIsTerminal = false;
/**
* **「刚说过话」要按记忆序列判,不按对话项判**(2026-08-25 柴浩:「上一步的卡片跑到
* 这一步的下面来了」)。从前只看 `out` 的末项是不是还没挂卡的 AI 发言 —— 可决策卡 /
* 录入卡的 card-sent、人的答复、内部观察都**不进 out**,在它眼里那句话一直「空着」:
* 实测 I01 一单,「两份初稿…请审核」那句话后面发的是决策卡(另一路合并),41 秒后
* 人答完、模型收口、**验收卡被挂到了上一步那句话上**,以它的时刻排到了决策卡前面。
* 紧邻 = 这张卡与那句话之间没有任何别的记忆条目(装配戳这类信封件除外)。
*/
let sayable = false;
for (const e of entries) {
const id = cardIdSchema.parse(e.id);
const envelope = e.kind === 'thought' && e.title.startsWith('装配技能');
if (e.kind === 'narration') {
out.push({ k: 'ai', id, at: e.at, text: e.title, tag: tagOf(e.detail) });
lastIsTerminal = false;
sayable = true;
continue;
}
if (!envelope && e.kind !== 'card-sent') sayable = false;
/*
* 触发事件与系统观察在对话区**长一个样**(都是灰头像的系统气泡)——
* 分家分在归侧与日志上,不在对话区:人在对话流里要的是「发生了什么」,
@@ -126,8 +138,11 @@ export function projectChat(entries: readonly NarrativeEntry[]): ChatMessage[] {
}
if (e.kind === 'card-sent') {
const ref = workOrderRef(e.detail);
// 跨人卡进对方的消息要素,不在本任务对话流上露面
if (ref === undefined) continue;
// 跨人卡 / 决策卡 / 录入卡进别的路,不在这里露面 —— 但它们隔在话与工单之间,那句话就不再「刚说过」
if (ref === undefined) {
sayable = false;
continue;
}
// 验收件请人裁决 = 判据;执行件派工 = 指导
const tag = e.title.includes('验收') ? ('criterion' as const) : ('guidance' as const);
/*
@@ -141,10 +156,12 @@ export function projectChat(entries: readonly NarrativeEntry[]): ChatMessage[] {
* 就把卡挂到那句话上,不另起一条。没说话就自己成一条 —— 卡不能不出现。
*/
const last = out[out.length - 1];
if (last?.k === 'ai' && last.ref === undefined && !lastIsTerminal) {
if (sayable && last?.k === 'ai' && last.ref === undefined && !lastIsTerminal) {
out[out.length - 1] = { ...last, tag, ref: workOrderIdSchema.parse(ref) };
sayable = false; // 一句话只领一张卡
continue;
}
sayable = false;
out.push({ k: 'ai', id, at: e.at, text: e.title, tag, ref: workOrderIdSchema.parse(ref) });
continue;
}
@@ -168,7 +168,7 @@ export function renderRows(html: string, scope: Readonly<Record<string, unknown>
* `data-file="地址/主键值"`:点它就是点开那一份文件(外层接住,见 ViewContainer)。
* 模版这一侧照旧只做取值,「点了去哪」不在这份 HTML 里。
*/
const address = path.replace(/^mine:/, '');
const address = path.replace(/^(?:mine|touched):/, '');
const body = rows
.map((row, index) => {
const one = (typeof row === 'object' && row !== null ? row : { value: row }) as Record<string, unknown>;
@@ -59,7 +59,8 @@ export function createInteractionService(deps: InteractionServiceDeps): Interact
by,
};
await deps.store.appendMessage(taskId, message);
await deps.memory.append({ taskId, kind: 'human-input', title: text, detail: { by } });
// 带上消息 id —— 对话区按记忆序号排序时要靠它认出这条话的落点(chat-order.ts)
await deps.memory.append({ taskId, kind: 'human-input', title: text, detail: { by, id: message.id } });
await deps.events.publishHumanEvent({ taskId, cardId: message.id, payload: { kind: 'free-input', text } });
broadcast({ kind: 'chat', taskId, message });
return message;
@@ -41,4 +41,44 @@ describe('parseJsonLoose:模型出参的宽容提取', () => {
it('实在不是 JSON:undefined,不抛', () => {
expect(parseJsonLoose('这不是 JSON')).toBeUndefined();
});
/*
* 2026-08-24 实测形(E04 收口拍,全流程括号最深的输出):模型在 profile 行上
* **少吐了一个 `}`** —— 行对象没关上,后面关 rows 的 `]` 被 drop 档当噪声丢掉,
* 整段错位、到尾配不平,两连败退化成兜底卡。close 档把欠的那个补上。
*/
it('**少吐一个闭括号:自动补上,整拍不作废**(实测形状,E04 收口拍)', () => {
// 第一行 patch 后少了关「行对象」的 } —— ]}]},{ 处 drop 档会把 ] 丢掉
const slip =
'{"say":"收口","actions":[' +
'{"kind":"write","address":"customer.profile","rows":[{"key":{"customer_code":"CU-0142"},' +
'"patch":{"decision_chain":{"nodes":[{"node":"bt-01","covered":true}]}}]},' +
'{"kind":"write","address":"crm_coverage.candidate","rows":[{"key":{"customer_code":"CU-0142"},"patch":{"status":"covered"}}]}]}';
const got = parseJsonLoose(slip) as { actions: Array<{ kind: string; address: string }> };
expect(got.actions.map((a) => a.kind)).toEqual(['write', 'write']);
expect(got.actions[1]?.address).toBe('crm_coverage.candidate');
});
it('输出被截断(结尾整段闭括号没吐):欠的全补上,能救就救', () => {
const cut = '{"say":"写一半断了","actions":[{"kind":"write","rows":[{"patch":{"a":1';
const got = parseJsonLoose(cut) as { actions: Array<{ kind: string }> };
expect(got.actions[0]?.kind).toBe('write');
});
it('**少括号的第二形:行对象没关、后面直接跟下一行** —— 对象里逗号后冒出 { 就是上一个没关(实测形状)', () => {
// rows 第一行的 } 少了,紧接着 ,{"key"… —— 括号到尾能配平,strict 与前两档全看不出
const slip =
'{"say":"收口","actions":[{"kind":"write","address":"customer.profile","rows":[' +
'{"key":{"customer_code":"CU-0142"},"patch":{"decision_chain":{"nodes":[{"node":"bt-01"}]}},' +
'{"key":{"customer_code":"CU-E2E-ADD"},"patch":{"coverage_score":0}}]}]}';
const got = parseJsonLoose(slip) as { actions: Array<{ rows: Array<{ key: { customer_code: string } }> }> };
expect(got.actions[0]?.rows.map((r) => r.key.customer_code)).toEqual(['CU-0142', 'CU-E2E-ADD']);
});
it('两档顺序不能反:多括号那形仍走 drop 档,行动串一个不丢', () => {
// 与上面「多吐一个闭括号」同形 —— close 档若先跑,会提前关掉 actions 把 finish 截掉
const slip = '{"say":"x","actions":[{"kind":"write","rows":[{"patch":{"t":"A"}}]}},{"kind":"finish","itemId":"S1"}]}';
const got = parseJsonLoose(slip) as { actions: Array<{ kind: string }> };
expect(got.actions.map((a) => a.kind)).toEqual(['write', 'finish']);
});
});
@@ -95,7 +95,12 @@ export function parseJsonLoose(text: string): unknown {
try {
return JSON.parse(candidate);
} catch {
return firstBalanced(candidate);
/*
* 两档修复**有序**:先按「多余闭括号当噪声丢」(2026-08-02 那形),
* 不行再按「缺失闭括号自动补」(2026-08-24 那形)—— 顺序不能反:
* 补括号那一档对「多括号」的输出会提前把外层对象关掉,行动串被截半。
*/
return firstBalanced(candidate, 'drop') ?? firstBalanced(candidate, 'close');
}
}
@@ -117,20 +122,39 @@ export function parseJsonLoose(text: string): unknown {
* 后面多出来的照旧当噪声 —— 多写的那一份本就是下一拍的事。
* 字符串与转义照认,否则值里带 `}` 的会把栈带偏。
*/
function firstBalanced(text: string): unknown {
/*
* ── 2026-08-24:少一个闭括号的形,丢字符治不了,得**补** ────────────────
*
* E04 收口那一拍是全流程括号最深的输出(decision_chain + interaction_timeline
* 两个嵌套 JSON 列),关思考的模型在 profile 行上少吐了一个 `}`:
* `…"contactCode":"CU-0142-01"}]}}]}` —— 行对象没关上,后面的 `]`(关 rows)
* 在栈眼里就成了「对不上的闭括号」被当噪声丢掉,整段从此错位、到尾都配不平,
* 解析两连败,六个动作全对的一拍退化成「需要你判断下一步」的兜底卡。
*
* `close` 档的两条规则(仍只合成闭括号,不发明任何内容):
* · 闭括号对不上栈顶、但**栈更深处有它** → 把中间欠的闭括号补上再收它
* (那正是模型漏写的那几个);栈里压根没有 → 还是噪声,丢;
* · 文本走完栈没清空(输出被截断的形)→ 把欠的全补上。
*/
function firstBalanced(text: string, mode: 'drop' | 'close'): unknown {
const start = text.search(/[[{]/);
if (start < 0) return undefined;
const stack: string[] = [];
const kept: string[] = [];
let inString = false;
let escaped = false;
/** 上一个串外的有意义字符 —— close 档靠它认「对象里逗号后面冒出了 { 」 */
let lastSignificant = '';
for (let i = start; i < text.length; i += 1) {
const ch = text[i] as string;
if (inString) {
kept.push(ch);
if (escaped) escaped = false;
else if (ch === '\\') escaped = true;
else if (ch === '"') inString = false;
else if (ch === '"') {
inString = false;
lastSignificant = '"';
}
continue;
}
if (ch === '"') {
@@ -139,14 +163,35 @@ function firstBalanced(text: string): unknown {
continue;
}
if (ch === '{' || ch === '[') {
/*
* 2026-08-24 第二种少括号形:行对象没关上,后面直接跟 `,{"key":…`(下一行)。
* 括号本身到尾都能配平(少的那个 `}` 被后面某个多出来的位置吃掉),strict 与
* 前两档全看不出 —— 但**对象里逗号后面只能跟键名**,冒出 `{`/`[` 一定是上一个
* 对象没关。close 档:把欠的 `}` 补在那个逗号之前(逗号其实属于外层数组)。
*/
if (mode === 'close' && lastSignificant === ',' && stack[stack.length - 1] === '}') {
const comma = kept.lastIndexOf(',');
if (comma >= 0) {
kept.splice(comma, 0, '}');
stack.pop();
}
}
stack.push(ch === '{' ? '}' : ']');
kept.push(ch);
lastSignificant = ch;
continue;
}
if (ch === '}' || ch === ']') {
if (stack[stack.length - 1] !== ch) continue; // 对不上的闭括号 = 噪声,丢
if (stack[stack.length - 1] !== ch) {
// 对不上的闭括号:drop 档当噪声丢;close 档若栈更深处有它,先把欠的补上
if (mode === 'drop' || !stack.includes(ch)) continue;
while (stack.length > 0 && stack[stack.length - 1] !== ch) {
kept.push(stack.pop() as string);
}
}
stack.pop();
kept.push(ch);
lastSignificant = ch;
if (stack.length === 0) {
try {
return JSON.parse(kept.join(''));
@@ -157,6 +202,16 @@ function firstBalanced(text: string): unknown {
continue;
}
kept.push(ch);
if (!/\s/.test(ch)) lastSignificant = ch;
}
// 文本走完栈没清空:close 档把欠的闭括号全补上(输出被截断的形);drop 档照旧作废
if (mode === 'close' && stack.length > 0) {
while (stack.length > 0) kept.push(stack.pop() as string);
try {
return JSON.parse(kept.join(''));
} catch {
return undefined;
}
}
return undefined;
}
@@ -1737,3 +1737,57 @@ describe('同一张卡再发一遍的三档', () => {
expect(resendVerdict([sent(''), replied('')], ' ').kind).toBe('send');
});
});
/**
* **发过卡这件事要落住**(2026-08-24,柴浩那一单熔断逼出来的一组回归)。
*
* `worldTouched` 是 finish 那道门唯一的证据:人工激活的项完成与否不该由数据形状推出来,
* 只认「真给它发过一张卡」。那一单里这个标记不见了 —— 于是 finish 说「从没发过卡」、
* 重发说「人已答过」,两堵墙对着撞,五拍熔断。
* 查下来标记本身在这三条路上都落得住(下面三条钉死),丢是丢在**两个后端并发写同一份
* 局面**上(见 `workers/task-claim.ts`)—— 这三条测试保证的是:这一侧别再出新的洞。
*/
describe('worldTouched 要落进保存的局面', () => {
it('给人工激活的项发一张卡 → 保存下来的那一项标着 worldTouched', async () => {
const h = harness({
parsed: STAGE_MODEL,
runtime: READY_RUNTIME2,
action: { kind: 'human', itemId: 'GATED', card: { title: '回执工单', ask: '请回填' }, workOrder: 'execution' },
});
await h.runner.runTurn(TASK);
expect(h.issued).toHaveLength(1);
const gated = h.envelope.runtime.items.find((i) => i.id === 'GATED');
expect(gated?.worldTouched, 'GATED 上没有 worldTouched —— 发过的卡没被记住').toBe(true);
});
it('**再跑一拍之后仍然记得**(柴浩那一单:卡发过了,下一拍 finish 却说「从没发过卡」)', async () => {
// 桩按调用时读 over.action —— 留着这个引用,拍与拍之间换动作
const over: { parsed: CaseModel; runtime: CaseRuntime; action: Record<string, unknown> } = {
parsed: STAGE_MODEL,
runtime: READY_RUNTIME2,
action: { kind: 'human', itemId: 'GATED', card: { title: '回执工单', ask: '请回填' }, workOrder: 'execution' },
};
const h = harness(over);
await h.runner.runTurn(TASK);
expect(h.envelope.runtime.items.find((i) => i.id === 'GATED')?.worldTouched).toBe(true);
// 第二拍:干点别的(写一笔)—— 它会把局面重新存一遍
over.action = { kind: 'write', itemId: 'FREE', address: 'quote_intake.record', rows: [{ key: { pn: 'X' }, patch: { eau: 2 } }] };
await h.runner.runTurn(TASK);
const gated = h.envelope.runtime.items.find((i) => i.id === 'GATED');
expect(gated?.worldTouched, '第二拍存完之后,发过卡这件事被抹掉了').toBe(true);
});
it('**人把卡答回来之后仍然记得** —— 回音落库那一下也存局面', async () => {
const h = harness({
parsed: STAGE_MODEL,
runtime: READY_RUNTIME2,
action: { kind: 'human', itemId: 'GATED', card: { title: '回执工单', ask: '请回填' }, workOrder: 'execution' },
});
await h.runner.runTurn(TASK);
const cardId = 'card-1';
await h.runner.onEvent(TASK, { kind: 'human', ref: cardId, payload: { values: {} } });
const gated = h.envelope.runtime.items.find((i) => i.id === 'GATED');
expect(gated?.worldTouched, '人答回来之后,发过卡这件事被抹掉了').toBe(true);
});
});
+5
View File
@@ -23,6 +23,11 @@
port 注入:memory / event / message / interaction / ledger
被依赖: process, org(停用前置检查的在途任务), report, app
近变(2026-08-25):`TaskService.wake(taskId)` —— 人答复落库那一刻就把 runtime 翻成
`running` 并 setPhase('active')(顺带广播);只对 awaiting-event / awaiting-human 生效,
已终结 / suspended / 已在跑的原样不动并返回 false。回传路由在返回之前调它,
让对话区(卡状态)与工作区(runtime)读到同一份事实。
近变(2026-07-30):`Task` / `StartTaskInput` 加 `origin?`(`testbed` = 设计台试跑)+
`sys.task.origin` 列。**认得出试跑单才拔得掉** —— 工作流那侧靠
`source->>'ref'='testbed'` 认,任务这侧从前没有对应记号:
@@ -0,0 +1,80 @@
/**
* `wake` —— 人答复落库那一刻就把「在办事」写进投影(2026-08-25 用户:「右边聊天框先有反应,
* 中间的 html 过一会儿才变成 AI 正在推进中」)。从前 runtime 要等派发器投递、推进器抢到
* 租约、开跑前那一刻才翻;那几秒里对话区与工作区读到的是两份相反的事实。
*/
import { describe, expect, it } from 'vitest';
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(runtime: Task['runtime'], terminal?: Task['terminal']) {
const phases: string[] = [];
const task = {
id: 't-1',
activityId: 'e04',
ownerId: 's-1',
title: 'x',
runtime,
terminal,
data: {},
caps: { steps: 40, tokens: 200000, humanCalls: 2, activeMinutes: 60 },
used: { steps: 0, tokens: 0, humanCalls: 0, activeMs: 0 },
startedAt: '2026-08-25T00:00:00.000Z',
} as unknown as Task;
const tasks = new Map<string, Task>([[task.id, task]]);
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 () => new Date(),
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 () => ({ status: 'running' }), onEvent: async () => ({ status: 'running' }) },
memory: { append: async () => undefined, hasHistory: async () => true, seal: async () => undefined, distill: async () => undefined },
landing: { land: async () => ({ converged: true, emittedPorts: [] as string[], ledgerRowsMerged: 0, facts: {} as LandedFacts }) },
listeners: { hasArmedListener: async () => true },
persistence: { hasDrafts: async () => false, hasRuntimeState: async () => true },
events: { publishTerminal: async () => undefined, publishControl: async () => undefined },
notifier: { notify: async () => undefined },
interaction: { setPhase: async (_id, phase) => void phases.push(phase) },
intake: { askCompletion: async () => undefined },
comms: { record: async () => undefined },
clock: { now: () => new Date('2026-08-25T00:00:00.000Z') },
ids: { next: () => 't-x' },
};
return { service: createTaskService(deps), tasks, phases };
}
describe('task.wake —— 人答完那一刻就标「在办」', () => {
it('挂起等外部(awaiting-event)→ running,并把 phase 推成 active(顺带广播)', async () => {
const h = harness('awaiting-event');
expect(await h.service.wake('t-1' as never)).toBe(true);
expect(h.tasks.get('t-1')?.runtime).toBe('running');
expect(h.phases).toEqual(['active']);
});
it('挂起等人(awaiting-human)同样翻', async () => {
const h = harness('awaiting-human');
expect(await h.service.wake('t-1' as never)).toBe(true);
expect(h.tasks.get('t-1')?.runtime).toBe('running');
});
it('**已在跑 / 暂停 / 已终结的原样不动**,返回 false、不广播', async () => {
for (const [runtime, terminal] of [['running', undefined], ['suspended', undefined], ['terminal', 'released']] as const) {
const h = harness(runtime, terminal);
expect(await h.service.wake('t-1' as never), runtime).toBe(false);
expect(h.tasks.get('t-1')?.runtime).toBe(runtime);
expect(h.phases).toEqual([]);
}
});
});
+7
View File
@@ -103,6 +103,13 @@ export type TaskService = {
/** 控制三语义:暂停 / 叫停 / 终止 */
control(taskId: TaskId, semantic: ControlSemantic, by: StaffId, reason?: string): Promise<ControlOutcome>;
resume(taskId: TaskId, by: StaffId): Promise<Task>;
/**
* **人答复落库那一刻就把「在办事」写进投影**(2026-08-25):不等派发器投递、推进器
* 抢到租约再翻 —— 那几秒里对话区(卡状态已落)与工作区(读 runtime)说两句相反的话。
* 只在挂起等人 / 等外部时翻;已终结、暂停(suspended)、已在跑的原样不动。
* 返回翻没翻 —— 调用方据此决定要不要广播。
*/
wake(taskId: TaskId): Promise<boolean>;
/** 改派:在途任务的负责人变更,**留痕并通知双方** */
reassign(taskId: TaskId, to: StaffId, by: StaffId): Promise<Task>;
@@ -393,6 +393,16 @@ export function createTaskService(deps: TaskServiceDeps): TaskService {
return outcome;
},
async wake(taskId) {
const task = await require(taskId);
if (task.terminal !== undefined) return false;
if (task.runtime !== 'awaiting-event' && task.runtime !== 'awaiting-human') return false;
await deps.store.update(taskId, { runtime: 'running' });
// setPhase 顺带广播(interaction 的 onPush → broadcaster),浏览器立刻重取
await deps.interaction.setPhase(taskId, 'active');
return true;
},
async resume(taskId, by) {
const task = await require(taskId);
if (task.terminal !== undefined) throw new TaskAlreadyTerminalError(taskId, task.terminal);