A2-A7 全部补齐到验收线;顺手把几处「门拦住却不给路」的坑填了

活动:A2 选型 / A3 寻源 / A4 定价 / A6 议价 / A7 归档五件各补成真活动包 ——
契约宽进(input.required 空,归一在活动内)、台账必填声明(含行级 requiredWhen)、
入场哨兵与 L1/L2 判据一律读基座数、两份件(在办可编辑 + 办结静态快照)、
三案例常备、包内自测。e2e 各三案例 released、行动被拒 0、发口正确、提交闸每单真拦。

基座(共享面,改一处影响所有活动,故每处都带回归):
· 重发判断走在站上场之前 —— 人答完那步常已收掉,readyToWork 先抛「不能再往它上面挂活」,
  于是「重发已答过的卡 = 无事发生」这条唯一活路在最需要时够不着,A1 基线当场熔断;
· 决策卡补幂等兜底(与执行工单同一把尺):头两次无事发生,第三次起才算被拒;
· 输出没成形先就地重问一次,原话进工作日志、不再摆上卡面;
· 工具调用读数 tool_<名>_calls —— 「机检只读基座数」这条纪律第一次覆盖到工具;
· 执行卡 steps/standard 补进出参 schema(从前能到卡面全靠厂商 JSON 模式宽松);
· 件的就地更新改逐节点 morph,保存先把新值镜像进只读那份 —— 不闪、不等回声。

A1(基线,全程守住):措辞三档对齐(必填/建议/选填)、禁止拿历史价当客户目标价、
卡骨架从步骤剧本搬进说明书(压成一拍连做时剧本没摊开,会发出没有指导书的裸卡)。

契约两处**新增可选字段**(非破坏):FieldDeclaration.requiredWhen、工单 finalHtml;
api-report 同批更新。docs/adr 与 docs/archive 的 13 份文件在工作树里不见了,
本次予以恢复而非提交删除。

门禁:typecheck / lint / depcruise / test(1366)全绿。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-03 12:28:31 +08:00
co-authored by Claude Opus 5
parent 85466e815f
commit ade0fa5a94
90 changed files with 17969 additions and 2316 deletions
+9 -3
View File
@@ -50,11 +50,17 @@
- 地址与列名的把关在装配层(它认识活动清单)—— 未声明的地址与列一律拒写,
越域写与越权调工具同罪;拒了抛错,由回合收成「行动被拒」喂回下一拍,引擎不崩;
- **写的授权面是台账声明,不是计划项**(2026-07-30):`itemId` 只是记账,
挂得上就挂(项活性化),挂不上(已完成 / 人工激活未 start / 阶段 / 不存在)照写不拦,
挂得上就挂(项活性化),挂不上(已完成 / 阶段 / 不存在)照写不拦,
留痕注明「未挂到计划项」。从前绑死在项生命周期上,死锁过一单:S5 收完才发现打标
没写全,所有能挂的项都 Completed —— 每拍补写都被拒,连拒五拍熔断,而数据本身合规;
- 写完把该项收了(未 Active 的就地过一下 Active)。**这不是绕开「Enabled ≠ Active」**:
那条纪律管的是触达世界的项,而草稿写触达不到任何东西 —— 为它单烧一次模型去按那一下是纯仪式。
- 写完把该项收了(未 Active 的就地过一下 Active)。
- **人工激活的项:模型显式动它 = 启动它**(2026-08-02 拆墙,`readyToWork`)。
从前它要求先发显式 `start`,不发就拒;提示词与拒回都写全之后实测仍熔断
(A1 一单连拒五拍,五次全是「S3_confirm 要先 start」)—— 那道墙保护不了任何东西:
start 是**模型自己的**动作,同一串连发即过,拦下只是逼它多演一步学不稳的礼仪。
人工激活真正的分量原样保留:**引擎不因哨兵满足替它启动**(哨兵只推到
Available/Enabled),**也不给 doneWhen 替人收**(自动收步跳过人工激活项)——
收它的只能是模型自己的 finish。`start` 仍是合法行动,只是可省。
- **里程碑前提达成即达成**(2026-07-30,`achieveMilestones`):CMMN 的里程碑没有执行体,
也没有谁去按它。从前只有 `onEvent(ref = 里程碑 id)` 会 occur,而系统里没有任何东西
发这种事件 —— 于是里程碑永远 `Available`:既是必做项(堵死 autoComplete),
@@ -6,7 +6,7 @@
*/
import { describe, expect, it } from 'vitest';
import type { TaskId } from '@contracts';
import { createTurnRunner, handsOff, sameGist, stalledTurns } from '../internal/usecase/turn-runner.ts';
import { createTurnRunner, echoTitleOf, handsOff, resendVerdict, sameGist, stalledTurns } from '../internal/usecase/turn-runner.ts';
import type { TurnRunnerDeps } from '../internal/usecase/turn-runner.ts';
import type { PendingAcceptance } from '../port.ts';
import type { CaseModel } from '../internal/domain/cmmn-parse.ts';
@@ -521,12 +521,14 @@ describe('干活前先站上场', () => {
* 实测的死锁:模型收完 S5 才发现打标结果没写全,此时所有能挂的项都 Completed,
* 唯一没完成的只剩标人工激活的 T_release —— 每拍补写都被「要先 start」拒回来,
* 连拒五拍熔断交人,而那几行数据本身合法合规。
* 2026-08-02 拆墙后,人工激活的项也**挂得上**了(动它就算启动):
* 写照落不变,记账从「挂不上、留痕注明」变成「挂上、点亮进行中」—— 更诚实。
*/
it('标了人工激活的项:写照落、不挂项、项不动 —— 挂项是记账,不是授权', async () => {
it('标了人工激活的项:写照落、挂上点亮 —— 授权面仍是台账声明', async () => {
const h = harness({ parsed: STAGE_MODEL, runtime: READY_RUNTIME2, action: writeTo('GATED') });
const outcome = await h.runner.runTurn(TASK);
expect(h.written).toHaveLength(1);
expect(outcome.runtime.items.find((i) => i.id === 'GATED')?.state).toBe('Available');
expect(outcome.runtime.items.find((i) => i.id === 'GATED')?.state).toBe('Active');
expect(outcome.status).toBe('running');
});
@@ -629,11 +631,18 @@ describe('宣告完成', () => {
expect(outcome.runtime.items.find((i) => i.id === 'FREE')?.state).toBe('Completed');
});
it('标了人工激活的项仍要先 start —— 那条纪律管的是触达世界与关键交付', async () => {
/*
* **人工激活的项:动它就算启动**(2026-08-02 拆墙)。
* 从前这儿钉的是「要先 start,不发就拒」;实测那道墙保护不了任何东西
* (start 是模型自己的动作,同一串连发即过),却多出一种死法:A1 一单
* 连拒五拍熔断,五次全是「S3_confirm 要先 start」,提示词与拒回都写着,
* 模型就是学不稳。人工激活真正的分量在引擎那一侧:不替它启动、不替人收 ——
* 那两条各有各的测钉着(哨兵不自动激活 / 自动收步跳过人工激活项)。
*/
it('标了人工激活的项:模型显式动它 = 启动它,就地过 Active 再收', async () => {
const h = harness({ parsed: STAGE_MODEL, runtime: READY_RUNTIME2, action: finish('GATED') });
const outcome = await h.runner.runTurn(TASK);
expect(outcome.runtime.items.find((i) => i.id === 'GATED')?.state).toBe('Available');
expect(outcome.status).toBe('running'); // 拒了不崩,理由喂回下一拍
expect(outcome.runtime.items.find((i) => i.id === 'GATED')?.state).toBe('Completed');
});
it('编出来的项收不了', async () => {
@@ -784,7 +793,7 @@ describe('说话按轮不按拍:只有交出控制权的那一拍才进对话区
});
it('轮到人了才出气泡:问一句 / 派工单 / 摆岔路 / 等外部 / 提交放行', () => {
for (const kind of ['ask', 'human', 'decide', 'listen', 'submit']) {
for (const kind of ['human', 'decide', 'listen', 'submit']) {
expect(handsOff([{ kind }]), kind).toBe(true);
}
});
@@ -878,13 +887,11 @@ describe('画面不再静默丢帧', () => {
});
/**
* 补全卡 —— 对话卡族,与执行工单是两件事。
*
* SOP 上 A1-S2「按缺口清单追问补全」画的就是它。从前 Agent **发不出对话卡**:
* 行动型里只有 `human` 且只能选执行 / 验收两种工单,于是那一步只能拿执行工单顶 ——
* 形似而神非:派一张工单去问几个字段,收卡的人得进待处理、办完再回来,重得不成比例。
* **补全卡已全局退役**(2026-08-02 用户定)—— 引擎侧这个行动型不再发卡:
* 归一层(agent-runner)会把 `ask` 原地转成零输入执行工单,所以正常到不了这里;
* 别的调用方直发就如实拒并指对路(human + execution)。
*/
describe('补全卡', () => {
describe('补全卡退役:引擎不再发 elicitation 卡', () => {
const ASK = {
kind: 'ask',
itemId: 'i1',
@@ -892,18 +899,9 @@ describe('补全卡', () => {
fields: [{ key: 'NE555P_lead_time', label: 'NE555P 交期' }],
};
it('发出去即挂起等填 —— 调人 = 立监听听人', async () => {
it('直发 ask 被拒:一张卡都不发,理由指向执行工单', async () => {
const h = harness({ parsed: OPEN_MODEL, runtime: OPEN_RUNTIME, action: ASK });
const outcome = await h.runner.runTurn(TASK);
expect(outcome.status).toBe('awaiting');
expect(h.issued).toEqual([
{ workOrder: 'elicitation', card: { ask: '这两条缺交期,麻烦补一下', fields: ['NE555P_lead_time'] } },
]);
});
it('一个格子都没有就拒发 —— 空表单等于没问', async () => {
const h = harness({ parsed: OPEN_MODEL, runtime: OPEN_RUNTIME, action: { ...ASK, fields: [] } });
const outcome = await h.runner.runTurn(TASK);
expect(h.issued).toHaveLength(0);
expect(outcome.status).toBe('running'); // 拒了不崩,理由喂回下一拍
});
@@ -1225,6 +1223,30 @@ describe('办完的项:话要说对,按一下不报错', () => {
expect(h.issued).toHaveLength(0);
});
/*
* **A1 基线熔断实测(2026-08-03)**:人答完之后那一步已经收掉,模型又发了一遍同一张卡。
* 从前 `readyToWork` 走在重发判断之前 —— Completed 项当场抛「不能再往它上面挂活」,
* 于是「重发已答过的卡 = 无事发生」这条唯一活路在最需要它的时候够不着,
* 同一条拒回重复五遍熔断(13 拍 124k,发口一个都没有)。顺序换过来才叫墙要配路。
*/
it('重发**已答过**的卡,哪怕那一步已经收掉 → 无事发生,不撞「已办完」的墙', async () => {
const h = harness({
parsed: DONE_MODEL,
runtime: DONE_RUNTIME,
action: { kind: 'human', itemId: 'S3', workOrder: 'execution', card: { title: '请审定这份清单', ask: 'a' } },
trail: [
{ kind: 'card-sent', title: '请审定这份清单' },
{ kind: 'card-reply', title: '请审定这份清单' },
] as never,
});
const outcome = await h.runner.runTurn(TASK);
// 没发第二张卡(人不被打扰、humanCalls 不烧),也没被拒
expect(h.issued).toHaveLength(0);
expect(outcome.status).not.toBe('awaiting');
// 收掉的项原样收着 —— 无事发生不该顺手把它推回场上
expect(h.envelope.runtime.items.find((i) => i.id === 'S3')?.state).toBe('Completed');
});
it('**再 start 一次收掉的项是无事发生,不是非法迁移** —— 别让两堵墙对着撞', async () => {
const h = harness({ parsed: DONE_MODEL, runtime: DONE_RUNTIME, action: { kind: 'start', itemId: 'S3' } });
const outcome = await h.runner.runTurn(TASK);
@@ -1233,3 +1255,44 @@ describe('办完的项:话要说对,按一下不报错', () => {
expect(h.envelope.runtime.items.find((i) => i.id === 'S3')?.state).toBe('Completed');
});
});
/**
* **重发已答过的卡:执行工单与决策卡同一把尺**(2026-08-03,A4 实测逼出来的)。
*
* 从前只有执行工单那一路有兜底,决策卡一道都没有 —— 同一个岔路口连发四张,
* 每张扣一次调人预算,四次扣完整单 `exhausted`。卡族不同,病是同一个。
* 三档由 `resendVerdict` 定:照发 / 无事发生(头两次)/ 第三次起当被拒(熔断看得见)。
*/
describe('同一张卡再发一遍的三档', () => {
const sent = (title: string) => ({ kind: 'card-sent', title });
const replied = (title: string) => ({ kind: 'card-reply', title });
const echoed = (answered: string) => ({ kind: 'observation', title: echoTitleOf(answered) });
it('没发过这一问 → 照发', () => {
expect(resendVerdict([sent('别的事'), replied('别的事')], '这一问').kind).toBe('send');
});
it('发过但**人还没答** → 照发 —— 未答的重发由卡自己去重管,不归这里', () => {
expect(resendVerdict([sent('这一问')], '这一问').kind).toBe('send');
});
it('人答过了 → 头两次无事发生(卡不真发、预算不烧)', () => {
const win = [sent('这一问'), replied('这一问')];
expect(resendVerdict(win, '这一问')).toEqual({ kind: 'echo', answered: '这一问' });
expect(resendVerdict([...win, echoed('这一问')], '这一问').kind).toBe('echo');
});
it('第三次起当被拒 —— 那时是真卡死了,熔断兜底要看得见它', () => {
const win = [sent('这一问'), replied('这一问'), echoed('这一问'), echoed('这一问')];
expect(resendVerdict(win, '这一问')).toEqual({ kind: 'reject', answered: '这一问' });
});
it('问句只少个尾巴也算同一问 —— 变体重发同样烧预算(与 sameQuestion 同源)', () => {
const win = [sent('请审定这份受理需求清单,确认无误后我就往下走'), replied('请审定这份受理需求清单,确认无误后我就往下走')];
expect(resendVerdict(win, '请审定这份受理需求清单').kind).toBe('echo');
});
it('空问句不拦 —— 认不出身份的卡不该被这道兜底吃掉', () => {
expect(resendVerdict([sent(''), replied('')], ' ').kind).toBe('send');
});
});
@@ -101,9 +101,16 @@ function payloadOf(detail: Record<string, unknown> | undefined, limit: number =
* 人以为自己白点了一次。补全卡那一路早就有(`values`),决策卡是漏的那一半。
*/
const chosen = detail['value'];
const note = detail['note'];
if (typeof chosen === 'string' && chosen !== '') {
const note = detail['note'];
parts.push(`人选了:${chosen}${typeof note === 'string' && note !== '' ? `(${note})` : ''}`);
} else if (typeof note === 'string' && note !== '') {
/*
* 没有选项也可能有话要带 —— 零输入执行卡的空答复痕靠这一句说清
* 「人已办完并交回」,不然痕上一个值都没有,模型会当「没答成」再发一张卡
* (2026-08-02 实测熔断单)。
*/
parts.push(note);
}
const output = detail['output'];
@@ -186,7 +193,47 @@ export function sameQuestion(a: string, b: string): boolean {
const norm = (s: string): string => s.replace(/[\p{P}\p{S}\s]/gu, '').toLowerCase();
const x = norm(a);
const y = norm(b);
return x !== '' && x === y;
if (x === '' || y === '') return false;
if (x === y) return true;
/*
* **前缀也算同一件事**(2026-08-02 用户实测送来的单):第一张
* 「请审定这份受理需求清单,确认无误后我就往下走。」人答了;第二张
* 「请审定这份受理需求清单」—— 只少了个尾巴,全等比对放它过去,
* 又烧掉一次人环预算(那单 humanCalls 4/4 耗尽,放行卡都没发出去)。
* 短的一方够长(≥6 字)且是长方前缀,就是同一问 —— 阈值保守:
* 拦错一问比多问一问更坏,真的新问题不该被吃掉。
*/
const [short, long] = x.length <= y.length ? [x, y] : [y, x];
return short.length >= 6 && long.startsWith(short);
}
/** 「已答过、没有再发」那条观察痕的标题 —— 记与数是同一句话,不能各写各的 */
export function echoTitleOf(answered: string): string {
return `这张卡人已答过,没有再发(「${answered}」)`;
}
/**
* **同一张卡该不该再发一遍** —— 执行工单与决策卡共用这一把尺(纯判断,好测)。
*
* 从前只有执行工单那一路有这道兜底,决策卡**一道都没有**:同一个岔路口连发四张,
* 每张扣一次调人预算,四次扣完整单 `exhausted`(2026-08-03 A4 实测:人点完之后
* 那一步被重念,剧本头一段写着「发一张决策卡」,它就照办再发一张)。
* 卡族不同,病是同一个 —— **按钮按两下不该变成墙**,也不该变成四张卡。
*
* 三档:`send` 照发;`echo` 卡不真发(人不被打扰、humanCalls 不烧),
* 只把「已答过」的事实摆回轨迹;第三次起 `reject` —— 那时是真卡死了,熔断要看得见它。
*/
export function resendVerdict(
window: ReadonlyArray<{ readonly kind: string; readonly title: string }>,
asked: string,
): { readonly kind: 'send' } | { readonly kind: 'echo' | 'reject'; readonly answered: string } {
const trimmed = asked.trim();
if (trimmed === '') return { kind: 'send' };
const replied = new Set(window.filter((t) => t.kind === 'card-reply').map((t) => t.title));
const same = window.find((t) => t.kind === 'card-sent' && replied.has(t.title) && sameQuestion(t.title, trimmed));
if (same === undefined) return { kind: 'send' };
const echoed = window.filter((t) => t.kind === 'observation' && t.title === echoTitleOf(same.title)).length;
return { kind: echoed >= 2 ? 'reject' : 'echo', answered: same.title };
}
/**
@@ -220,7 +267,7 @@ export function handsOff(actions: ReadonlyArray<{ kind: string }>): boolean {
return actions.some((a) => HANDOFF_KINDS.has(a.kind));
}
const HANDOFF_KINDS = new Set(['ask', 'human', 'decide', 'listen', 'submit']);
const HANDOFF_KINDS = new Set(['human', 'decide', 'listen', 'submit']);
export function sameGist(a: string, b: string): boolean {
const norm = (s: string): string => s.replace(/[\p{P}\p{S}\s]/gu, '');
@@ -304,11 +351,15 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
/**
* 干活的那一下:把计划项推到 Active,再由调用方收成 Completed。
*
* 拓扑不许 `Available → Completed`,所以中间那一步总得有人走。谁走,分情况:
* · 项标了 `manualActivationRule` → **只能 Agent 自己 start**。那条纪律管的是
* 触达世界与关键交付:按一下再动,为的是别让 AI 悄悄改了外面。
* · 没标 → 引擎就地过一下。为一次读类调用或一次草稿写单烧一拍模型去按那一下,
* 是纯仪式;实测里它还会卡死:模型反复挑同一个没 start 的项,每拍都被拓扑拒回来。
* 拓扑不许 `Available → Completed`,所以中间那一步总得有人走 —— **引擎就地过一下**,
* 人工激活的项也一样(2026-08-02 拆墙)。从前人工激活的项要求模型先发一个显式
* `start`,不发就拒;可那道墙保护不了任何东西:start 是**模型自己的**动作,
* 同一串里连发即过,拦下只是逼它多演一步礼仪 —— 而礼仪学不稳就是死法:
* 实测 A1 一单连拒五拍熔断,五次全是「S3_confirm 要先 start」,提示词与拒回
* 都写着这句话,它就是学不会。人工激活真正要保的两样一样不少:
* 引擎不因哨兵满足**替它启动**(那在 sentry 那一侧),也不给 doneWhen
* **替人收步**(那在自动收步那一侧)。「模型显式动它」本身就是启动的意思表示,
* 与 finish 就地过 Active、start 已收项无事发生同族 —— 状态机的礼仪不该长成墙。
*
* 项不存在就如实报,并把合法的选项摆出来 —— 模型会编 id(实测编过),
* 只回一句「(不存在)」它下一拍还是照编。
@@ -357,9 +408,6 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
`要接着办就挂当下这一步(可选:${live.join('、') || '无'});这一单该收口了就直接 submit`,
);
}
if (item.manualActivation) {
throw new Error(`${itemId} 标了人工激活(触达世界或关键交付),要先 start 再干活 —— 当下是 ${state}`);
}
return transitionItem(runtime, itemId, 'Active');
}
@@ -667,8 +715,15 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
* 拒了不掀翻回合,草稿也确实没被改脏(写进去的值与原值相同)。
*/
if (changed === 0) {
/*
* 「换一件事做」四个字不够指方向(2026-08-02 实测:A2 一单连撞四拍,
* 每拍都把同一行原样再写一遍)—— 拒回的话是模型下一拍的行动指南,
* 得说清「这些值**已经在**台账里」意味着什么:多半是该办下一件事了。
*/
throw new Error(
`写了等于没写:${action.address} 这 ${action.rows.length} 行的值与现有草稿完全相同,局面没有推进 —— 换一件事做`,
`写了等于没写:${action.address} 这 ${action.rows.length} 行的值与现有草稿完全相同 —— ` +
`这些值**已经在台账里了**,不必再写。该办的多半是下一件事:还没写的列/表、` +
`该 finish 的项,或按剧本走下一步;把同样的值再写一遍只会再被拒一次`,
);
}
await deps.memory.append({
@@ -755,11 +810,19 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
* Active → Active 是非法迁移,当场拒;模型只会原样再试一遍,连拒五拍熔断。
* 而它想做的事完全合法 —— 一个计划项本来就可以先问一句、再派一张单。
*
* `readyToWork` 是同一件事的宽容写法:已在场就原样返回,人工激活未 start 才拦。
* `readyToWork` 是同一件事的宽容写法:已在场就原样返回,没在场就地站上
* (2026-08-02 起人工激活的项同样如此 —— 动它就算启动)。
*/
const { parsed: cardModel } = await loaded(taskId);
const advanced = readyToWork(cardModel, runtime, action.itemId);
/*
* **重发的判断要走在站上场之前**(2026-08-03,A1 熔断实测逼出来的顺序)。
*
* 原先先 `readyToWork` 再判重发:而人答完之后那一步常常已经**收掉**了,
* `readyToWork` 对 Completed 项直接抛「这一步已经办完,不能再往它上面挂活」——
* 于是「重发已答过的卡 = 无事发生」这条唯一的活路**在最需要它的时候够不着**,
* 模型原样重发、连拒五拍熔断(A1 基线实测:13 拍 124k,同一条拒回重复 5 遍)。
* 重发一张已答过的卡本来就不需要那一项在场 —— 它什么也不做。
* 「墙要配路」(设计律第一条):先给路,给不了路再谈墙。
*
* **同一件事不许问第二遍**(2026-08-01,实测账)。
*
* 熔断管的是「连续被拒」,管不了这种:每一拍的行动都合法,只是**问的是同一件事**。
@@ -775,19 +838,44 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
const asked = String(face['title'] ?? face['ask'] ?? '').trim();
if (asked !== '') {
const window = await deps.memory.window(taskId, STALL_WINDOW);
const sent = window.filter((t) => t.kind === 'card-sent');
const replied = new Set(window.filter((t) => t.kind === 'card-reply').map((t) => t.title));
const same = sent.find((t) => replied.has(t.title) && sameQuestion(t.title, asked));
if (same !== undefined) {
throw new Error(
`这件事刚问过,人也答过了(「${same.title}」)—— 别再发一张一样的卡。` +
'答案就在你的轨迹里(card-reply 那一条的 values)。' +
'**他把格子留空提交回来,就是对方给不了** —— 那也是一个答案:' +
'如实记账(问过了 / 答过了),缺口照实留着,把这一步收掉往下走;' +
'真有别的东西要问,换一件事、换一个问法。',
);
const verdict = resendVerdict(window, asked);
if (verdict.kind !== 'send') {
const same = { title: verdict.answered };
/*
* **重发已答过的卡:头两次当无事发生,不当被拒**(2026-08-02 用户实测
* 送来的熔断单)。从前这里直接拒 —— 拒回的话把「答案在轨迹里」说得
* 再清楚,模型偶发就是读不进:零输入执行卡的空答复在它眼里像「没答成」,
* 于是原样重发、被拒、再重发,五拍熔断,人被无谓叫来。
* 拒回喂的是对抗,观察喂的是事实 —— 与 start 已收项无事发生、
* finish 已收项无事发生同族:**按钮按两下不该变成墙**。
* 卡不真发(人不被打扰、humanCalls 不烧),只把「已答过」的事实
* 连着下一步指路摆回去;第三次起仍按被拒算 —— 那时是真卡死了,
* 熔断兜底要看得见它。
*/
const echoTitle = echoTitleOf(same.title);
if (verdict.kind === 'reject') {
throw new Error(
`这件事刚问过,人也答过了(「${same.title}」)—— 别再发一张一样的卡。` +
'答案就在你的轨迹里(card-reply 那一条)。**空答复 = 人照原样确认了**,' +
'不是没答:接着办说明书里「人交回之后」的那几件事,把这一步收掉往下走;' +
'真有别的东西要问,换一件事、换一个问法。',
);
}
await deps.memory.append({
taskId,
kind: 'observation',
title: echoTitle,
detail: {
internal: true,
note: '人已答过这张卡(空答复 = 照原样确认)。别再发了 —— 接着办说明书里「人交回之后」的那几件事,办完把这一步 finish 掉。',
},
});
// 什么也没做,局面原样交回 —— 这一拍不该顺手把那一项推上场
return { runtime, status: 'running' };
}
}
const { parsed: cardModel } = await loaded(taskId);
const advanced = readyToWork(cardModel, runtime, action.itemId);
const card = await deps.cards.issue({
taskId,
ownerId: context.ownerId,
@@ -806,32 +894,12 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
case 'ask': {
/*
* 发补全卡 = 立监听听人:卡发到对话区,本拍就此挂起等填。
*
* **先验迁移,再发卡**:迁移非法时抛在前面,世界上不会留下一张已发出的卡 ——
* 卡收不回,负责人的对话区就堆起一摞谁也不知道该怎么答的表单。
* **补全卡已全局退役**(2026-08-02 用户定:「彻底全局退役补全卡,
* 执行卡完全就是包含补全语义的人类工具」)。归一层(agent-runner)已把
* `ask` 原地转成零输入执行工单,这个分支正常到不了 —— 真到了
* (别的调用方直发)就如实拒,并指对路。
*/
if (action.fields.length === 0) {
throw new Error(
'补全卡要有格子:一个缺口一个格子,形状 fields:[{key,label,hint?}] —— 空表单等于没问',
);
}
const advanced = readyToWork((await loaded(taskId)).parsed, runtime, action.itemId);
const card = await deps.cards.ask({
taskId,
ownerId: context.ownerId,
humanCallCap: context.humanCallCap,
ask: action.ask,
fields: action.fields,
...(action.basis === undefined ? {} : { basis: action.basis }),
});
await deps.listeners.register({
taskId,
eventType: 'human',
keyFilter: { cardId: card.id },
deadline: 'P3D',
});
return { runtime: advanced, status: 'awaiting' };
throw new Error('补全卡已退役:缺信息发零输入执行工单(human + workOrder:"execution"),指导书写清缺什么,人在工作区的件上补');
}
case 'entry': {
@@ -875,7 +943,38 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
* 以及先验迁移再发卡(与补全卡同理 —— 卡发出去收不回)。
*/
if (action.options.length < 2) {
throw new Error('决策卡至少两个选项:只有一条路就不是决策,直接干或者发补全卡');
throw new Error('决策卡至少两个选项:只有一条路就不是决策,直接干或者派一张执行工单');
}
/*
* 同款顺序:**先判重发,再站上场**(见 human 那一路的账 —— 人选完之后
* 那一步常已收掉,先站上场就会撞在「已办完」的墙上,活路够不着)。
*
* **人已经选过的岔路口不再发第二张**(2026-08-03,A4 实测:同一张连发四次,
* 四次调人预算烧光落 `exhausted`)。与执行工单同一把尺 —— 决策卡的身份是问句(`ask`),
* 它入痕时记的正是 `ask`(`issueChatCard` → `card-sent` 标题)。
*/
const window = await deps.memory.window(taskId, STALL_WINDOW);
const verdict = resendVerdict(window, action.ask);
if (verdict.kind !== 'send') {
const echoTitle = echoTitleOf(verdict.answered);
if (verdict.kind === 'reject') {
throw new Error(
`这个岔路口人已经选过了(「${verdict.answered}」)—— 别再发一张一样的决策卡。` +
'他选了什么就在你的轨迹里(card-reply 那一条,detail 带着选中的值):' +
'照那个选择往下办,把这一步收掉;真有**另一个**岔路口要人定,换一件事、换一个问法。',
);
}
await deps.memory.append({
taskId,
kind: 'observation',
title: echoTitle,
detail: {
internal: true,
note: '人已经在这个岔路口选过了(选中的值在 card-reply 那一条)。别再发 —— 照他的选择接着办,办完把这一步 finish 掉。',
},
});
// 什么也没做,局面原样交回
return { runtime, status: 'running' };
}
const advanced = readyToWork((await loaded(taskId)).parsed, runtime, action.itemId);
const card = await deps.cards.decide({
@@ -931,11 +1030,12 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
/*
* 宣告完成。完成不等于成果被认可:终局仍唯放行,人验收才算数。
*
* 没站上场的普通项**就地过 Active 再收**(readyToWork 同一套):这一项的活
* 没站上场的项**就地过 Active 再收**(readyToWork 同一套):这一项的活
* 常常在同一串里已经干了(write 落了行),只是记账没挂上 —— 为「按一下」
* 单烧一拍模型是纯仪式(实测:finish S6 被「没在进行中」拒,下一拍
* start S6 + finish 原样重来,白花一拍)。标了人工激活的仍要先 start:
* 那条纪律管的是触达世界与关键交付,readyToWork 会替它把门。
* start S6 + finish 原样重来,白花一拍)。人工激活的项也一样(2026-08-02
* 拆墙,见 readyToWork 注释)—— 但它仍旧**没有 doneWhen、引擎不自动收**,
* 收它的只能是模型自己的这一下 finish。
*/
const { parsed: finishModel } = await loaded(taskId);
/*