Archived
fix(loop-engine): 收尾复审二审 —— 兜底卡自己会抛 / 清单兜底不看面孔 / 空转写解闸
复审逐条核实上一波(C2/C3/M1/M2)修法本身引入的三处破坏,全部属实并修死: - I1 二审:配方办不下去时发的那张兜底卡(deps.cards.issue)从前裸调,调人预算 触顶抛 HumanCallBudgetExceededError 会精确复现 C3 那种无限热循环(推进器每 2 秒重来、不烧预算、不换状态、界面纹丝不动)。包进 try/catch,失败留痕、 状态退回 awaiting,不让它抛出去。配了变异验证:拿掉 try/catch 后新用例 真的变红(runTurn 抛出预算触顶错误),还原后转绿。 - C2 二审:decisionCardFace 的 checklist 兜底从前不看 reviewFace —— 一步只声明 acceptanceChecklist 而 cardVariant 不是 review(T_release 那种形状)时,配方发 的普通 decide 卡也会被兜上清单,前端不渲染勾选区、回传门却要求 checked ⊇ checklist,卡按不下去。改成只在 reviewFace 为真时才退回剧本声明的清单。 - M1/M2 二审:project 动作里 localExisting/localUnkeyed 触发的观察痕与真写 (localWrote/localDropped)共用 detail.auto + detail.ledger 这两个键,而 raiseAcceptance 的 revised 判据只认这两个键在不在 —— 一颗料 skipExisting、 或一行主键算不出来,这条"什么都没动"的痕照样能解开重做闸,人打回重做、 配方原样重跑一遍,闸却开了。auto-runner 补 detail.changed(真写了/真撤了 才为真),revised 判据对配方那一路改认这个标记。 另两笔记账: - save-wake.test.ts 的用例标题写着"验收态就是这一种(没有卡在等人)"是错的, pendingCards 把未办结工单也算在内,验收态恰恰有一张开着的验收工单——只改 标题措辞,不动断言与实现。 - docs/debt.md 补两笔:保存后唤醒的收窄几乎不生效(pendingCards 口径过宽); 模型路径的写同样会 un-land、只补了配方那一半(独立起一条,排在 W05 重写前)。 pnpm gates 退出码 0(2410 passed / 23 skipped); git diff --stat b995148...HEAD -- activities/ 为空,九个活动包一字未动。 报告:.superpowers/sdd/2026-08-29-deterministic-steps-auto-recipe/closing-fix-report.md Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
This commit is contained in:
@@ -46,6 +46,29 @@ describe('决策卡的面孔与清单(装配层兜底)', () => {
|
||||
expect(face.checklist).toEqual(RELEASE_STEP.acceptanceChecklist);
|
||||
});
|
||||
|
||||
/**
|
||||
* **清单兜底得看 `reviewFace`**(2026-08-29 复审 I2 二审)。
|
||||
*
|
||||
* `isReleaseStep`(turn-runner.ts)认的形状是:一步声明了 `acceptanceChecklist`(给终局
|
||||
* `AcceptForm` 用)而 `cardVariant` 不是 `review`(T_release 本身就是这种形状)。配方若在
|
||||
* **这一步**发一张普通 `as:'decide'` 卡(没传 variant),从前这里照样把清单兜上去 ——
|
||||
* 前端只在 `variant === 'review'` 时才渲染清单,人看不到勾可打,回传门却要求
|
||||
* `checked ⊇ checklist`:卡按不下去。
|
||||
*/
|
||||
const NON_REVIEW_STEP_WITH_CHECKLIST = { acceptanceChecklist: ['逐条核对差异明细'] };
|
||||
|
||||
it('**非审核面孔的步声明了清单(T_release 那种形状),配方发普通决策卡:清单不兜上去**(I2 的二审正主)', () => {
|
||||
const face = decisionCardFace({}, NON_REVIEW_STEP_WITH_CHECKLIST);
|
||||
expect(face.reviewFace).toBe(false);
|
||||
expect(face.checklist).toBeUndefined();
|
||||
});
|
||||
|
||||
it('同一步:调用方明确递了 variant:review,清单才兜上去 —— reviewFace 是唯一的开关', () => {
|
||||
const face = decisionCardFace({ variant: 'review' }, NON_REVIEW_STEP_WITH_CHECKLIST);
|
||||
expect(face.reviewFace).toBe(true);
|
||||
expect(face.checklist).toEqual(NON_REVIEW_STEP_WITH_CHECKLIST.acceptanceChecklist);
|
||||
});
|
||||
|
||||
it('查不查剧本:没 itemId 查不了;两样都递全了不必查', () => {
|
||||
expect(needsStepLookup({})).toBe(false);
|
||||
expect(needsStepLookup({ itemId: 'T_release' })).toBe(true);
|
||||
|
||||
@@ -36,7 +36,13 @@ describe('件上保存之后', () => {
|
||||
* 人每按一次保存就白烧一步、界面闪一次「AI 正在推进这单」;任务本来 awaiting-human
|
||||
* (等人裁决)时被叫醒,那一拍结束还会把它降级成 awaiting-event:等的明明还是人。
|
||||
*/
|
||||
it('没有卡在等人(验收态就是这一种):件照旧重推,**不叫醒**', async () => {
|
||||
/*
|
||||
* **这条用例测的是 `pendingCards` 现读为空的那一种局面 —— 不是验收态**(2026-08-29 复审
|
||||
* A 项二审:这里原话写着「验收态就是这一种」,是错的)。`pendingCards`
|
||||
* (`human-tool-service.ts:374`)把**未办结工单**也算在内,而验收态恰恰有一张开着的
|
||||
* 验收工单 —— 真在验收态时 `pendingCards` 不空,`wake` 照样会发,不落进这条分支。
|
||||
*/
|
||||
it('没有卡在等人(pendingCards 现读为空):件照旧重推,**不叫醒**', async () => {
|
||||
const { calls, deps } = spy([]);
|
||||
await afterSave(deps, TASK, 3);
|
||||
expect(calls).toEqual(['repush', 'pending']);
|
||||
|
||||
@@ -233,6 +233,14 @@ export function createOnlyViolation(
|
||||
* **一条待确认都没有**:回传门 `checkChatCardReply` 的 `checklistMissing` 在 checklist 缺席时**恒过**,
|
||||
* 「确认通过」当场可按 —— 2026-08-27 用户当面定的那道人工验收闸凭空没了,而屏上还看不出来
|
||||
* (清单区是空的,不是红的)。兜在这一处而不是让每个调用方自己记得:**模型路径将来漏传也一并保住**。
|
||||
*
|
||||
* **清单兜底得看 `reviewFace`**(2026-08-29 复审 I2 二审):`isReleaseStep` 那条口径决定了
|
||||
* 一步可以只声明 `acceptanceChecklist`(给终局那张 `AcceptForm` 用)而不带 `cardVariant:'review'`
|
||||
* (T_release 本身就是这种形状)。配方若在**这一步**发一张普通的 `as:'decide'` 卡(既没传
|
||||
* `variant`,`step.cardVariant` 也不是 `review`),从前这里照样把 `acceptanceChecklist` 兜上去 ——
|
||||
* 前端只在 `variant === 'review'` 时才渲染清单(`chat-bubbles.tsx`),人看不到勾可打,
|
||||
* 而 `checkChatCardReply` 又要求 `checked ⊇ checklist`:卡按不下去。**只在 `reviewFace` 为真时**
|
||||
* 才退回剧本声明的清单 —— 非审核面孔的卡,清单就是没有,不发明一条按不下去的。
|
||||
*/
|
||||
export function decisionCardFace(
|
||||
input: { readonly variant?: 'review' | undefined; readonly checklist?: ReadonlyArray<string> | undefined },
|
||||
@@ -240,7 +248,11 @@ export function decisionCardFace(
|
||||
): { reviewFace: boolean; checklist: ReadonlyArray<string> | undefined } {
|
||||
const reviewFace = input.variant === 'review' || step?.cardVariant === 'review';
|
||||
const checklist =
|
||||
input.checklist !== undefined && input.checklist.length > 0 ? input.checklist : step?.acceptanceChecklist;
|
||||
input.checklist !== undefined && input.checklist.length > 0
|
||||
? input.checklist
|
||||
: reviewFace
|
||||
? step?.acceptanceChecklist
|
||||
: undefined;
|
||||
return { reviewFace, checklist };
|
||||
}
|
||||
|
||||
|
||||
@@ -621,6 +621,46 @@ describe('createOnly + 配方:已在库的当「不用建」跳过,不抛(I2)',
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* **`detail.changed` 只在真写了/真撤了时为真**(2026-08-29 复审 M1/M2 二审)。
|
||||
*
|
||||
* M1/M2 让「一行没写」也出声(`localExisting`/`localUnkeyed`),而**台账其实一个字没动**——
|
||||
* 重做闸(`turn-runner.raiseAcceptance` 的 `revised`)靠这个标记分清「配方真改过台账」
|
||||
* 与「配方跑过一遍但什么也没落」,两者都会留痕,只有前者该解锁闸。
|
||||
*/
|
||||
describe('project 留痕:changed 只在真写了/真撤了时为真(M1/M2 二审)', () => {
|
||||
it('真写了一行:changed:true', async () => {
|
||||
const { runner, appended, ctx } = harness({});
|
||||
await runner.run(
|
||||
{ do: [{ kind: 'project', into: 'sub_eval.part', from: { once: true }, fields: { eval_ref: 'W05-1', internal_pn: 'A' } }] },
|
||||
ctx,
|
||||
);
|
||||
const note = appended.find((a) => a.detail?.['ledger'] === 'sub_eval.part');
|
||||
expect(note?.detail?.['changed']).toBe(true);
|
||||
});
|
||||
|
||||
it('全在库、这一笔不用建(skipExisting):changed:false —— 台账没动', async () => {
|
||||
const { runner, formal, appended, ctx } = harness({});
|
||||
formal.add('sub_eval.part|W05-1|IN_LIB');
|
||||
await runner.run(
|
||||
{ do: [{ kind: 'project', into: 'sub_eval.part', from: { once: true }, fields: { eval_ref: 'W05-1', internal_pn: 'IN_LIB' } }] },
|
||||
ctx,
|
||||
);
|
||||
const note = appended.find((a) => a.detail?.['ledger'] === 'sub_eval.part');
|
||||
expect(note?.detail?.['changed']).toBe(false);
|
||||
});
|
||||
|
||||
it('主键算不出来、一行都没写:changed:false —— 台账没动', async () => {
|
||||
const { runner, appended, ctx } = harness({});
|
||||
await runner.run(
|
||||
{ do: [{ kind: 'project', into: 'sub_eval.part', from: { rows: [{ pn: '' }] }, fields: { eval_ref: 'W05-1', internal_pn: { col: 'pn' } } }] },
|
||||
ctx,
|
||||
);
|
||||
const note = appended.find((a) => a.detail?.['ledger'] === 'sub_eval.part');
|
||||
expect(note?.detail?.['changed']).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* **`carded` 只跳过这一笔,不收拍**(2026-08-29 终审 I3)。
|
||||
*
|
||||
|
||||
@@ -99,6 +99,8 @@ function harness(
|
||||
declaredListeners?: ReadonlyArray<{ eventType: string; keyFilter: Record<string, string> }>;
|
||||
/** 这一单还有哪几张卡在等人交回(不给 = 一张都没有 —— 老测试照跑) */
|
||||
unanswered?: ReadonlyArray<{ id: string; title: string }>;
|
||||
/** `deps.cards.issue` 这一句改抛(测「兜底卡自己也发不出去」——2026-08-29 复审 I1 二审) */
|
||||
issueRejects?: string;
|
||||
} = {},
|
||||
): Harness {
|
||||
const issued: Harness['issued'] = [];
|
||||
@@ -179,6 +181,7 @@ function harness(
|
||||
} as never,
|
||||
cards: {
|
||||
issue: async (input: { workOrder: string; card: Record<string, unknown> }) => {
|
||||
if (over.issueRejects !== undefined) throw new Error(over.issueRejects);
|
||||
issued.push({ workOrder: input.workOrder, card: input.card });
|
||||
return { id: `card-${issued.length}` };
|
||||
},
|
||||
@@ -2512,11 +2515,20 @@ describe('auto 步 · submit 抛错不许让回合崩(C3)', () => {
|
||||
auto: { do: [{ kind: 'submit', rationale: '配方判齐了' }] },
|
||||
};
|
||||
const REDO = { kind: 'result', title: '验收被打回重做:结论写得太糊' };
|
||||
/** 配方写下的那一笔:`observation` + `detail.auto` + `detail.ledger`(auto-runner 的真实形状) */
|
||||
/** 配方写下的那一笔:`observation` + `detail.auto` + `detail.ledger` + `detail.changed:true`(auto-runner 的真实形状) */
|
||||
const AUTO_WROTE = {
|
||||
kind: 'observation',
|
||||
title: '差异明细:写下 2 行',
|
||||
detail: { auto: 'T_release#1', ledger: 'substitution.difference_item' },
|
||||
detail: { auto: 'T_release#1', ledger: 'substitution.difference_item', changed: true },
|
||||
};
|
||||
/**
|
||||
* **空转的那一笔**(2026-08-29 复审 M1/M2 二审):`localExisting`/`localUnkeyed` 也会留痕
|
||||
* (M1/M2 的「一行没写也要出声」),而台账其实一个字没动 —— `detail.changed` 是 `false`。
|
||||
*/
|
||||
const AUTO_NOOP = {
|
||||
kind: 'observation',
|
||||
title: '差异明细:写下 0 行,2 行已在库、这一笔不用建',
|
||||
detail: { auto: 'T_release#1', ledger: 'substitution.difference_item', changed: false },
|
||||
};
|
||||
|
||||
it('打回后配方原样重提 → 留痕拒回、**状态 running**(不抛,推进器不再空转热循环)', async () => {
|
||||
@@ -2547,7 +2559,7 @@ describe('auto 步 · submit 抛错不许让回合崩(C3)', () => {
|
||||
expect(out.status).toBe('awaiting');
|
||||
});
|
||||
|
||||
it('**配方改过台账就算改过** —— observation + detail.auto + detail.ledger 也解得开重做闸', async () => {
|
||||
it('**配方改过台账就算改过** —— observation + detail.auto + detail.ledger + detail.changed:true 也解得开重做闸', async () => {
|
||||
const h = harness({
|
||||
parsed: AUTO_RELEASE,
|
||||
runtime: AUTO_RELEASE_RUNTIME,
|
||||
@@ -2560,6 +2572,26 @@ describe('auto 步 · submit 抛错不许让回合崩(C3)', () => {
|
||||
expect(out.status).toBe('awaiting');
|
||||
expect(h.appended.some((e) => e.title.startsWith('提交放行被拒'))).toBe(false);
|
||||
});
|
||||
|
||||
/**
|
||||
* **「一行没写」也能解开重做闸**(2026-08-29 复审 M1/M2 二审,复审跟到底的那条):
|
||||
* 人点了「重做」,配方重跑、一个字没改(全 skipExisting / 主键算不出来),只要留了
|
||||
* M1/M2 那条「出声」的痕,从前的 `revised` 判据一样为真 —— 闸放行了原样重提。
|
||||
*/
|
||||
it('空转的那一笔(全在库/主键算不出来)解不开重做闸 —— 拒回原样再来一遍', async () => {
|
||||
const h = harness({
|
||||
parsed: AUTO_RELEASE,
|
||||
runtime: AUTO_RELEASE_RUNTIME,
|
||||
sfeelCondition: () => false,
|
||||
trail: [REDO, AUTO_NOOP],
|
||||
steps: [RELEASE_STEP],
|
||||
});
|
||||
const out = await h.runner.runTurn(TASK);
|
||||
expect(out.status).toBe('running');
|
||||
expect(h.issued).toHaveLength(0);
|
||||
const refused = h.appended.find((e) => e.title.startsWith('提交放行被拒'));
|
||||
expect(refused?.title).toContain('一个字没改');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -2612,6 +2644,33 @@ describe('auto 步 · 配方办不下去要给人一条路(I1)', () => {
|
||||
await h.runner.runTurn(TASK);
|
||||
expect(h.issued).toHaveLength(0);
|
||||
});
|
||||
|
||||
/**
|
||||
* **兜底卡自己也会抛 —— 精确复现 C3 那种无限热循环**(2026-08-29 复审 I1 二审)。
|
||||
*
|
||||
* `deps.cards.issue` 经 `human-tool-service.assertBudget`,调人预算触顶时抛
|
||||
* `HumanCallBudgetExceededError`。这一句从前**裸着**调:抛出去 `runTurn` 崩、
|
||||
* `engine.advance` / `task-service.tick` 都不捕获、`task-advancer` 只写 stderr 就
|
||||
* break,任务停在 `running`,下一个 2 秒 interval 再来一遍 —— 不烧预算、不换状态、
|
||||
* 界面纹丝不动。这条用例断言 `runTurn` **不抛**、状态落 `awaiting`、留了一条痕。
|
||||
*/
|
||||
it('连兜底卡都发不出去(如调人预算触顶):不抛、状态落 awaiting、留痕', async () => {
|
||||
const h = harness({
|
||||
parsed: AUTO_MODEL,
|
||||
runtime: AUTO_RUNTIME,
|
||||
sfeelCondition: () => false,
|
||||
steps: [BROKEN],
|
||||
issueRejects: '调人预算已经触顶(3/3)',
|
||||
});
|
||||
const out = await h.runner.runTurn(TASK);
|
||||
expect(out.status).toBe('awaiting');
|
||||
expect(h.issued).toHaveLength(0);
|
||||
expect(h.listened).toHaveLength(0);
|
||||
const note = h.appended.find((e) => e.title.includes('连兜底卡都没能发出去'));
|
||||
expect(note).toBeDefined();
|
||||
expect(note?.title).toContain('调人预算已经触顶(3/3)');
|
||||
expect(String(note?.detail?.['hint'])).toContain('nope.nope');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -198,6 +198,13 @@ export function createAutoRunner(deps: AutoRunnerDeps): {
|
||||
* (「芯片主数据:写下 2 行」)会一笔一个气泡跑进对话区,而对话区的口径是
|
||||
* 「一轮一句,不是一拍一句」。工作日志那一侧由 `machinery()` 对 `detail.auto`
|
||||
* 开的例外放行(与 `detail.say` 同款)——**两件事各有各的开关**。
|
||||
*
|
||||
* **`detail.changed`**(2026-08-29 复审 M1/M2 二审):这一笔只要有 `localExisting`
|
||||
* 或 `localUnkeyed` 就会留痕(M1/M2 立的规矩——「一行没写也要出声」),而这两种局面
|
||||
* **台账其实一个字没动**。重做闸(`raiseAcceptance` 的 `revised`)认的是
|
||||
* 「配方写下的这一笔」,不点名区分就会把「全表已在库、这一笔不用建」这类空转
|
||||
* 也当成「改过了」放行 —— 人打回重做,配方原样重跑一遍、一个字没改,闸却开了。
|
||||
* 只有真写了或真撤了才算改过世界。
|
||||
*/
|
||||
await deps.memory.append({
|
||||
taskId: context.taskId,
|
||||
@@ -209,7 +216,12 @@ export function createAutoRunner(deps: AutoRunnerDeps): {
|
||||
(localDropped === 0 ? '' : `,撤掉 ${localDropped} 行(${nameList(dropNames)})`) +
|
||||
/* 少写了几行要出声(M1):静默 continue 的那几行,表里少了谁也查不出为什么 */
|
||||
(localUnkeyed === 0 ? '' : `,${localUnkeyed} 行因主键算不出来没写(主键列:${decl.keys.join('、')})`),
|
||||
detail: { auto: `${context.stepId}#${index + 1}`, ledger: action.into, internal: true },
|
||||
detail: {
|
||||
auto: `${context.stepId}#${index + 1}`,
|
||||
ledger: action.into,
|
||||
internal: true,
|
||||
changed: localWrote > 0 || localDropped > 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
return { wrote: localWrote, dropped: localDropped };
|
||||
|
||||
@@ -1542,10 +1542,21 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
* 只认 action 的话,一个全 auto 的放行步在被打回之后**改了台账这道闸也看不见** ——
|
||||
* 每一拍都抛,而下面那条软化路(`refusedBefore`)靠的是模型路径 catch 里写的痕,
|
||||
* 配方路径从前没有 catch,那条痕永远不写:**这堵墙对配方是永远开不了的**。
|
||||
*
|
||||
* **配方那一路要求真写了或真撤了**(2026-08-29 复审 M1/M2 二审):`project` 动作里
|
||||
* `localExisting`(已在库、这一笔不用建)与 `localUnkeyed`(主键算不出来没写)也会
|
||||
* 留痕(M1/M2 立的「一行没写也要出声」),两者台账其实一个字没动。只看
|
||||
* `detail.auto` + `detail.ledger` 这两个键在的话,一颗料 skipExisting、或一行主键
|
||||
* 算不出来,这条痕就照样解锁 —— 人打回重做,配方原样重跑,闸却开了(正是这道闸
|
||||
* 要拦的空转重提)。auto-runner 现在把「真改过」摆成 `detail.changed`,这里照它认;
|
||||
* `kind:'action'` 那一支不必看这个键 —— 模型的 write/drop 一行没变就直接拒(见
|
||||
* :980/:1075),能落成 `kind:'action'` 的一定是真改过的。
|
||||
*/
|
||||
const revised = after.some(
|
||||
(e) => (e.kind === 'action' || typeof e.detail?.['auto'] === 'string') && typeof e.detail?.['ledger'] === 'string',
|
||||
);
|
||||
const revised = after.some((e) => {
|
||||
if (typeof e.detail?.['ledger'] !== 'string') return false;
|
||||
if (e.kind === 'action') return true;
|
||||
return typeof e.detail?.['auto'] === 'string' && e.detail?.['changed'] === true;
|
||||
});
|
||||
const refusedBefore = after.some((e) => e.kind === 'result' && e.title.includes(REFUSE_MARK));
|
||||
if (!revised && !refusedBefore) {
|
||||
throw new Error(
|
||||
@@ -2026,34 +2037,59 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
* 照熔断兜底卡的成例:发一张卡把办不下去的那一笔原话摆给负责人,立人事件监听。
|
||||
* 走到这儿时这一单**没有**未答的卡(runTurn 开头 `cards.unanswered` 把住了),
|
||||
* 所以不会堆卡;人答完之后若仍失败,再发一张 —— 那是实情,由调人预算兜底。
|
||||
*
|
||||
* **这一句「由调人预算兜底」本身就是引爆点**(2026-08-29 复审 I1 二审):
|
||||
* `deps.cards.issue` 走的是 `human-tool-service.assertBudget`,调人预算一旦触顶
|
||||
* 就抛 `HumanCallBudgetExceededError` —— 而这一段从前是**裸着**调的,一抛就精确
|
||||
* 复现 C3 那种无限热循环(`engine.advance` / `task-service.tick` 都不捕获,
|
||||
* `task-advancer` 只写 stderr 就 break,任务停在 `running`、界面纹丝不动、每 2 秒重来)。
|
||||
* 与 :2073(C3 那处)同款收场:抛了不能让回合崩,退回旧行为(留痕 + `awaiting`)
|
||||
* 总好过热循环。
|
||||
*/
|
||||
const stuck = await deps.tasks.of(taskId);
|
||||
const failed = auto.failedAt;
|
||||
const card = await deps.cards.issue({
|
||||
taskId,
|
||||
ownerId: stuck.ownerId,
|
||||
humanCallCap: stuck.humanCallCap,
|
||||
workOrder: 'execution',
|
||||
card: {
|
||||
title: `${AUTO_FAIL_MARK}:「${current.item}」第 ${failed.index + 1} 笔`,
|
||||
ask:
|
||||
'这一步由系统按配方确定性执行,它停在了下面这一笔上 —— 不重试、不退回模型(那正是这套机制要消灭的东西)。' +
|
||||
'看原因判断:是数据还差点什么(补齐了这一单就能接着走),还是活动包的配方写错了(去设计台改完重跑)。',
|
||||
basis: [
|
||||
{ k: '这一步', v: current.item },
|
||||
{ k: '第几笔', v: `${failed.index + 1}` },
|
||||
{ k: '办不下去的原因', v: failed.message },
|
||||
],
|
||||
fields: ['decision'],
|
||||
},
|
||||
});
|
||||
await deps.listeners.register({ taskId, eventType: 'human', keyFilter: { cardId: card.id }, deadline: 'P3D' });
|
||||
/*
|
||||
* 这里**不再补一句交代**:办不下去的那句话 auto-runner 已经落成系统观察进了对话区
|
||||
* (「配方第 N 笔办不下去:… —— 交给你处理」),发卡本身也落 `card-sent`。
|
||||
* 再写一条 narration 就是同一件事说三遍,而且 narration 是 AI 蓝 ——
|
||||
* 停下来的是系统按配方走不下去,不是 AI 判断如此(spec §5.9:配方那一笔归系统灰)。
|
||||
*/
|
||||
try {
|
||||
const card = await deps.cards.issue({
|
||||
taskId,
|
||||
ownerId: stuck.ownerId,
|
||||
humanCallCap: stuck.humanCallCap,
|
||||
workOrder: 'execution',
|
||||
card: {
|
||||
title: `${AUTO_FAIL_MARK}:「${current.item}」第 ${failed.index + 1} 笔`,
|
||||
ask:
|
||||
'这一步由系统按配方确定性执行,它停在了下面这一笔上 —— 不重试、不退回模型(那正是这套机制要消灭的东西)。' +
|
||||
'看原因判断:是数据还差点什么(补齐了这一单就能接着走),还是活动包的配方写错了(去设计台改完重跑)。',
|
||||
basis: [
|
||||
{ k: '这一步', v: current.item },
|
||||
{ k: '第几笔', v: `${failed.index + 1}` },
|
||||
{ k: '办不下去的原因', v: failed.message },
|
||||
],
|
||||
fields: ['decision'],
|
||||
},
|
||||
});
|
||||
await deps.listeners.register({ taskId, eventType: 'human', keyFilter: { cardId: card.id }, deadline: 'P3D' });
|
||||
/*
|
||||
* 这里**不再补一句交代**:办不下去的那句话 auto-runner 已经落成系统观察进了对话区
|
||||
* (「配方第 N 笔办不下去:… —— 交给你处理」),发卡本身也落 `card-sent`。
|
||||
* 再写一条 narration 就是同一件事说三遍,而且 narration 是 AI 蓝 ——
|
||||
* 停下来的是系统按配方走不下去,不是 AI 判断如此(spec §5.9:配方那一笔归系统灰)。
|
||||
*/
|
||||
} catch (error) {
|
||||
/* 兜底卡本身发不出去 —— 人要知道为什么停了,痕里把两层原因都摆出来 */
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
await deps.memory.append({
|
||||
taskId,
|
||||
kind: 'result',
|
||||
title: `配方办不下去,连兜底卡都没能发出去:${reason}`,
|
||||
detail: {
|
||||
auto: current.item,
|
||||
hint:
|
||||
`本该交给人的那句话:「第 ${failed.index + 1} 笔 —— ${failed.message}」。` +
|
||||
'发不出去多半是调人预算已经触顶(去治理台把这一单的调人预算提上去),' +
|
||||
'也可能是活动包配方写错了(去设计台改完重跑)。',
|
||||
},
|
||||
});
|
||||
}
|
||||
return {
|
||||
runtime: stateRuntime,
|
||||
enabled: stateEnabled,
|
||||
|
||||
Reference in New Issue
Block a user