Archived
fix(engine): 人点「重做」不是「办完了」—— 打回即重开一轮,不再一路推到放行
用户实测:「我重做后怎么直接推进了?不应该是重做刚才那一步吗?」那一单的轨迹逐条对得上: 审核卡发出 → 人带意见交回 →「这一步已经发过卡,不再发第二张」→「自动收步:S3_review」 → 下一拍发出落账放行的验收卡。意见落进了轨迹(还被 note-anchors 逐行对上了), 而再没有任何东西会去读它。 这条缺陷 2026-08-30 全分支终审已记录成 Important 2、并留着一条坐实病灶的测试, 连「半个修法会把单子卡死」都预判对了。照那个方向修完,两半缺一不可: ① 收步那道门认答复(answerSettlesStep:custom:(人没选任何选项、写了自己的话)与 约定的 reject 都不算办完;空答复照旧算「照原样确认」); ② 同一句判断也让 carded 让位 —— 幂等挡的是「同一问被重复发出去」,而人点重做正是说 「这一轮不算,改了再问我一遍」:配方重跑(该重算的重算)再发一张,日志里说一句 「人把这一步打回重做:<意见>」。 测:坐实那条改写成钉死修复后的行为(历史诊断原样留着);新增「重做但发不出新卡也不收步」 (这一条专管收步门那一半)与「通过照旧收步」;3 条纯函数测。两半各自变异验证。 e2e w05 5/5 released —— 案例 0 现在真的发出两张审核卡(先带意见驳回、再确认通过)。 顺带撞见一条独立的老毛病,已记 debt 未修:人答得比发卡那一拍收尾还快时,回传被判 stale、 wake 又被那一拍的 park 覆盖,单子停在 awaiting-event 再没人叫醒(手工拨回 running 立刻走完)。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
This commit is contained in:
@@ -1834,6 +1834,25 @@
|
||||
.dossiers 三列网格(1fr·76px·1fr),中间列是实体动画道 —— ⇄ 徽呼吸 + 双点绕徽轨道
|
||||
对转(sw-orbit),总线虚线在道里连两卡;窄屏单列收道。浏览器两帧相位实证。
|
||||
|
||||
- **「我重做后怎么直接推进了?」—— 打回与通过在收步那道门眼里一模一样**(2026-08-31 夜,
|
||||
柴浩实测的那一单逐条对得上):审核卡发出 → 人带意见交回 →「这一步已经发过卡,不再发第二张」
|
||||
→「**自动收步:S3_review —— 卡已交回,这一步到此为止**」→ 下一拍就发出了落账放行的验收卡。
|
||||
意见落进了轨迹(还被 note-anchors 逐行对上了),而再没有任何东西会去读它。
|
||||
**这条缺陷 2026-08-30 全分支终审时已被记录成 Important 2、还留着一条坐实病灶的测试**,
|
||||
连「半个修法会把单子卡死」都预判对了 —— 这次照它的方向修完:
|
||||
①收步那道门认答复(`answerSettlesStep`:`custom:`(人没选任何选项、写了自己的话)与约定的
|
||||
`reject` 都不算办完;空答复照旧算「照原样确认」);②**同一句判断也让 `carded` 让位** ——
|
||||
幂等挡的是「同一问被重复发出去」,而人点重做正是说「这一轮不算,改了再问我一遍」,
|
||||
那不是重复,是下一轮:配方重跑(该重算的重算)再发一张,并在日志里说一句「人把这一步打回重做:…」。
|
||||
只做①就是那个被预判过的半修法:挡住误收,单子卡死在「答过但没收步」的死胡同。
|
||||
三条引擎测(重做→重开 / 重做但发不出卡→也不收 / 通过→照旧收)+ 3 条纯函数测,
|
||||
两个半边各自变异验证;**e2e w05 5/5 released**,案例 0 现在真的发出**两张**审核卡
|
||||
(先带意见驳回、再确认通过)。
|
||||
**顺带撞见一条独立的老毛病并记账**(未修,见 debt.md):人答得比发卡那一拍收尾还快时,
|
||||
回传被判 `stale`、`wake` 又被那一拍的 park 覆盖,单子停在 `awaiting-event` 再没人叫醒 ——
|
||||
手工拨回 `running` 立刻走完。2026-08-27 那条 stale 守卫的前提(「推进过 = 回传已被消费」)
|
||||
在这种时序下不成立:把时刻往前挪的那一拍,正是发出这张卡的那一拍。
|
||||
|
||||
- **「你怎么给我发成了执行工单」—— 面孔在真发卡那条路上被丢掉**(2026-08-31 用户实测,
|
||||
`part-substitution-assessment@2.16.0`):装配层发执行工单那一处是一句**写死只认 `'data'`**
|
||||
的三目(`engines.ts`),配方递下来的 `variant: 'accept'` 静默丢掉 —— 对话区照旧摆「执行工单」。
|
||||
|
||||
@@ -8,7 +8,7 @@ import { describe, expect, it } from 'vitest';
|
||||
import type { TaskId } from '@contracts';
|
||||
import { deriveSide, structuredLog } from '@modules/memory';
|
||||
import type { EntryKind, GovernanceMark, MemoryEntry, VersionStamp } from '@modules/memory';
|
||||
import { createTurnRunner, echoTitleOf, handsOff, resendVerdict, sameGist, stalledTurns, toolInputHash, unmetWhy, answeredCardFact, earlyFinishTitleOf } from '../internal/usecase/turn-runner.ts';
|
||||
import { createTurnRunner, echoTitleOf, handsOff, resendVerdict, sameGist, stalledTurns, toolInputHash, unmetWhy, answeredCardFact, answerSettlesStep, lastCardAnswer, earlyFinishTitleOf } 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';
|
||||
@@ -2197,6 +2197,32 @@ describe('终局放行那一步:human 被拒并指向 submit', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
* **人答的这句话算不算「办完了」**(2026-08-31)—— 平台只判它知道的两件:
|
||||
* `custom:`(他没选任何一个选项、写的是自己的话)与约定的 `reject`;选项值的语义平台不知道。
|
||||
*/
|
||||
describe('answerSettlesStep / lastCardAnswer', () => {
|
||||
it('重做的两种形状都不算办完:写了理由的 custom:、以及验收面孔约定的 reject', () => {
|
||||
expect(answerSettlesStep('custom:结论算错了,请重算')).toBe(false);
|
||||
expect(answerSettlesStep('reject')).toBe(false);
|
||||
});
|
||||
|
||||
it('通过、空答复(照原样确认)、别的选项值 —— 都算办完', () => {
|
||||
expect(answerSettlesStep('approve')).toBe(true);
|
||||
expect(answerSettlesStep(undefined)).toBe(true);
|
||||
expect(answerSettlesStep('to-sourcing')).toBe(true);
|
||||
});
|
||||
|
||||
it('后面又发了别的卡 = 这条回传已经翻篇,不再算数', () => {
|
||||
const replied = [
|
||||
{ kind: 'card-sent', title: '结论审核' },
|
||||
{ kind: 'card-reply', title: '结论审核', detail: { value: 'custom:重算' } },
|
||||
];
|
||||
expect(lastCardAnswer(replied)?.chose).toBe('custom:重算');
|
||||
expect(lastCardAnswer([...replied, { kind: 'card-sent', title: '落账放行' }])).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('auto 步:配方在拍首跑', () => {
|
||||
/** 一步的活动:那一步声明 auto */
|
||||
const AUTO_MODEL: CaseModel = {
|
||||
@@ -2482,7 +2508,13 @@ describe('auto 步:配方在拍首跑', () => {
|
||||
});
|
||||
|
||||
/*
|
||||
* **Important 2(2026-08-30 全分支终审,先坐实、不修)**:上面那条「卡答过了…→ 收步」
|
||||
* **Important 2 已修**(2026-08-31 用户实测撞上它:「我重做后怎么直接推进了?不应该是
|
||||
* 重做刚才那一步吗?」—— 那一单的轨迹逐条对得上这里描述的每一步:审核卡发出 → 人带意见
|
||||
* 交回 →「这一步已经发过卡,不再发第二张」→「自动收步:S3_review —— 卡已交回,这一步到
|
||||
* 此为止」→ 下一拍就发出了落账放行的验收卡)。下面这段诊断原样留着 —— 它把病灶说得很准,
|
||||
* 连「半个修法会把单子卡死」都预判对了;修法见文末。
|
||||
*
|
||||
* 原记录:上面那条「卡答过了…→ 收步」
|
||||
* 只证明了「答过就收」这个结构本身存在,没证明它对**裁决**视而不见 —— 这条把裁决
|
||||
* 摆明白:审核卡只有一个选项(`approve`),人按的是「重做」,交的是
|
||||
* `custom:<理由>`(`review-face.ts` 的 `redoReply`:写了理由且 `allowCustom` 就交
|
||||
@@ -2505,42 +2537,73 @@ describe('auto 步:配方在拍首跑', () => {
|
||||
* `chosenGuidance` 头注里 2026-08-27 W05 实测的原话)在一条新的(fully-auto)代码路径上
|
||||
* 重演,而那次的修法结构性地够不到这条路径。
|
||||
*
|
||||
* **建议修法方向**(未实现,留给专门的一次改动):line 2179 那道门至少要能分清
|
||||
* 「答的是不是 approve 选项」——但这只是半个修法:即便挡住了误收,S3_review 的
|
||||
* `card` 动作在 `context.carded === true` 时会**直接跳过、不再发新卡**
|
||||
* (`auto-runner.ts` 的 card 分支),重做之后没有任何机制会把 `worldTouched` 重置、
|
||||
* 也没有任何机制会再发一张新的审核卡 —— 单子会卡死在「答过但没收步」的死胡同,
|
||||
* 与 T_release 那条**已经做好**的重做闭环(`raiseAcceptance`,2026-08-22/23:意见落轨迹、
|
||||
* 在途验收清掉、同一拍照常问模型)不是同一件事、也没有对应机制。真正修好需要给
|
||||
* 这条 auto-card 路径补一个类似 `raiseAcceptance` 的「重做即重开」闭环,不是一行判断。
|
||||
* **修法**(2026-08-31)—— 正是上面预判的那个「重做即重开」闭环,两半缺一不可:
|
||||
* ①收步那道门认答复(`answerSettlesStep`:`custom:` 与约定的 `reject` 都不算办完);
|
||||
* ②同一句判断也让 `carded` 让位 —— 幂等挡的是**同一问被重复发出去**,而人点重做正是说
|
||||
* 「这一轮不算,改了再问我一遍」,那不是重复,是下一轮:配方重跑(该重算的重算)再发一张。
|
||||
* 只做①就是那个预判过的半修法:挡住误收,单子卡死在「答过但没收步」的死胡同。
|
||||
*/
|
||||
it('Important 2 坐实:审核卡答的是"重做"(custom:<理由>),不是 approve —— 「卡答过了就收步」照样收步,意见对它不可见', async () => {
|
||||
const REVIEW_STEP = {
|
||||
item: 'S1',
|
||||
guide: '',
|
||||
writes: [],
|
||||
reads: [],
|
||||
tools: [],
|
||||
auto: { do: [{ kind: 'card' as const, as: 'decide' as const, variant: 'review' as const, title: '结论审核', options: [{ value: 'approve', label: '确认通过' }], basis: [{ k: 'a', v: 'b' }] }] },
|
||||
};
|
||||
const ANSWERED: CaseRuntime = { items: [{ id: 'S1', state: 'Active', worldTouched: true }] } as never;
|
||||
const replyOf = (value: string) => [{ kind: 'card-reply', title: '结论审核', detail: { value, by: 's-1' } }];
|
||||
|
||||
it('审核卡答的是「重做」(custom:<理由>):**这一步不收**,配方重开一轮把卡再发一张', async () => {
|
||||
const h = harness({
|
||||
parsed: AUTO_MODEL,
|
||||
runtime: { items: [{ id: 'S1', state: 'Active', worldTouched: true }] } as never,
|
||||
runtime: ANSWERED,
|
||||
unanswered: [],
|
||||
sfeelCondition: () => false,
|
||||
// 人真答的是"重做",理由写在 note 里 —— 这条 trail 证明"裁决是拒答"这件事**在系统里查得到**
|
||||
trail: [{ kind: 'card-reply', title: '结论审核', detail: { value: 'custom:结论算错了,请重算', by: 's-1' } }],
|
||||
steps: [
|
||||
{
|
||||
item: 'S1',
|
||||
guide: '',
|
||||
writes: [],
|
||||
reads: [],
|
||||
tools: [],
|
||||
auto: { do: [{ kind: 'card', as: 'decide', variant: 'review', title: '结论审核', options: [{ value: 'approve', label: '确认通过' }], basis: [{ k: 'a', v: 'b' }] }] },
|
||||
},
|
||||
],
|
||||
// 人真答的是「重做」,理由带在值里 —— 裁决是拒答这件事**在系统里查得到**
|
||||
trail: replyOf('custom:结论算错了,请重算'),
|
||||
steps: [REVIEW_STEP],
|
||||
});
|
||||
const out = await h.runner.runTurn(TASK);
|
||||
expect(out.runtime.items.find((i) => i.id === 'S1')?.state).not.toBe('Completed');
|
||||
/* 重开一轮 = 真的再发一张(只挡住误收就成了死胡同 —— 上面预判过) */
|
||||
expect(h.issued.map((i) => i.workOrder)).toEqual(['decision']);
|
||||
/* 打回这件事要在日志里说出来:人回头得看得见「它听见了」 */
|
||||
expect(h.appended.some((a) => a.title.includes('打回重做'))).toBe(true);
|
||||
});
|
||||
|
||||
/*
|
||||
* **重开那一半发不出卡时,收步这道门也得认答复** —— 两处各管一段:配方能重发时(上一条)
|
||||
* 这道门根本走不到;发不出来时(卡的 `when` 不成立 / 调人预算见底)才轮到它。
|
||||
* 少了它就是:人说了重做,卡没发出来,这一步照样被判「办完」,单子往前推 —— 与病灶同款。
|
||||
*/
|
||||
it('人说重做、而配方这一拍发不出新卡:也不许收步(那时该交人看一眼,不是替他签字)', async () => {
|
||||
const h = harness({
|
||||
parsed: AUTO_MODEL,
|
||||
runtime: ANSWERED,
|
||||
unanswered: [],
|
||||
sfeelCondition: () => false,
|
||||
trail: replyOf('custom:结论算错了,请重算'),
|
||||
/* 卡这一笔带 when,而 sfeelCondition 恒假 —— 这一拍发不出卡 */
|
||||
steps: [{ ...REVIEW_STEP, auto: { do: [{ ...REVIEW_STEP.auto.do[0]!, when: 'never = 1' }] } }],
|
||||
});
|
||||
const out = await h.runner.runTurn(TASK);
|
||||
expect(h.issued).toHaveLength(0);
|
||||
/*
|
||||
* **这就是 Important 2 的病灶**:trail 里明明白白记着人写的是"结论算错了,请重算"
|
||||
* (一次拒答),这一步却仍然被判 Completed —— 与「人按了确认通过」会得到的结果
|
||||
* 完全相同。`out.runtime` 对这条 trail 里的裁决视而不见。
|
||||
*/
|
||||
expect(out.runtime.items.find((i) => i.id === 'S1')?.state).not.toBe('Completed');
|
||||
});
|
||||
|
||||
it('审核卡答的是「确认通过」:照旧收步、不再发第二张(这条近道本来就是为它设的)', async () => {
|
||||
const h = harness({
|
||||
parsed: AUTO_MODEL,
|
||||
runtime: ANSWERED,
|
||||
unanswered: [],
|
||||
sfeelCondition: () => false,
|
||||
trail: replyOf('approve'),
|
||||
steps: [REVIEW_STEP],
|
||||
});
|
||||
const out = await h.runner.runTurn(TASK);
|
||||
expect(out.runtime.items.find((i) => i.id === 'S1')?.state).toBe('Completed');
|
||||
expect(h.issued).toHaveLength(0);
|
||||
});
|
||||
|
||||
/*
|
||||
|
||||
@@ -324,9 +324,9 @@ export function resendVerdict(
|
||||
* 只在「最后一次卡的往来是回传」时摆(后面又发了别的卡就说明有新问题在等,不摆);
|
||||
* 决策卡摆出选中的值,执行工单如实说「空答复 = 照原样确认」。
|
||||
*/
|
||||
export function answeredCardFact(
|
||||
export function lastCardAnswer(
|
||||
window: ReadonlyArray<{ readonly kind: string; readonly title: string; readonly detail?: Record<string, unknown> | undefined }>,
|
||||
): string {
|
||||
): { readonly title: string; readonly chose: string | undefined } | undefined {
|
||||
let at = -1;
|
||||
for (let i = window.length - 1; i >= 0; i -= 1) {
|
||||
if (window[i]?.kind === 'card-reply') {
|
||||
@@ -335,15 +335,40 @@ export function answeredCardFact(
|
||||
}
|
||||
}
|
||||
const reply = at < 0 ? undefined : window[at];
|
||||
if (reply === undefined) return '';
|
||||
const newer = window.slice(at + 1).some((t) => t.kind === 'card-sent' && !sameQuestion(t.title, reply.title));
|
||||
if (newer) return '';
|
||||
if (reply === undefined) return undefined;
|
||||
/* 后面又发了别的卡 = 有新问题在等,这条回传已经翻篇 */
|
||||
if (window.slice(at + 1).some((t) => t.kind === 'card-sent' && !sameQuestion(t.title, reply.title))) return undefined;
|
||||
const value = reply.detail?.['value'];
|
||||
const chose = typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined;
|
||||
const head = `【这张卡人已经答过了】「${reply.title.length <= 60 ? reply.title : `${reply.title.slice(0, 60)}…`}」`;
|
||||
return chose === undefined
|
||||
return { title: reply.title, chose: typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined };
|
||||
}
|
||||
|
||||
/**
|
||||
* **人的这条答复算不算「这一步办完了」**(2026-08-31 用户实测:「我重做后怎么直接推进了?
|
||||
* 不应该是重做刚才那一步吗?」)。
|
||||
*
|
||||
* 病:「卡答过了就收步」那条近道(全 auto 的步、人答完就收)**不看人答的是什么** ——
|
||||
* 审核卡上点「重做」并写了意见,与点「确认通过」在它眼里一模一样:当场收掉审核那一步、
|
||||
* 发出落账放行的验收卡。意见倒是落进了轨迹,可再没有任何东西会去读它。
|
||||
*
|
||||
* 平台只判它知道的那两件(选项值的语义平台不知道 —— 见 `chosenGuidance`):
|
||||
* · `custom:` 开头 = 他没选任何一个选项、写的是自己的话 —— 「重做」那一钮交上来的正是它;
|
||||
* · `reject` = 验收面孔约定的驳回项(`review-face.ts` 按这个值找那一钮)。
|
||||
* 空答复照旧算**照原样确认**(既有口径,见下面 `answeredCardFact` 那句话)。
|
||||
*/
|
||||
export function answerSettlesStep(chose: string | undefined): boolean {
|
||||
const value = chose?.trim() ?? '';
|
||||
return value !== 'reject' && !value.startsWith('custom:');
|
||||
}
|
||||
|
||||
export function answeredCardFact(
|
||||
window: ReadonlyArray<{ readonly kind: string; readonly title: string; readonly detail?: Record<string, unknown> | undefined }>,
|
||||
): string {
|
||||
const answer = lastCardAnswer(window);
|
||||
if (answer === undefined) return '';
|
||||
const head = `【这张卡人已经答过了】「${answer.title.length <= 60 ? answer.title : `${answer.title.slice(0, 60)}…`}」`;
|
||||
return answer.chose === undefined
|
||||
? `${head} → 人已交回(**空答复 = 照原样确认**,不是没答)。别再发一张一样的卡 —— 照说明书里「人交回之后」那一段办,办完 finish 这一项。`
|
||||
: `${head} → ${chosenGuidance(chose)}`;
|
||||
: `${head} → ${chosenGuidance(answer.chose)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2048,13 +2073,35 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
/* 配方**只认 `liveStep`** —— 见上面 `current` 兜底那条注释(I6) */
|
||||
const current = liveStep;
|
||||
const recipe = liveStep.auto;
|
||||
/*
|
||||
* **被打回就重开一轮**(2026-08-31 用户实测:「我重做后怎么直接推进了?」)。
|
||||
*
|
||||
* 「发过就不再发」的幂等挡的是**同一问被重复发出去**;而人点「重做」正是说
|
||||
* 「这一轮不算,按我的意见改了再问我一遍」—— 那不是重复,是下一轮。
|
||||
* 于是这一拍的 `carded` 让位:配方重新跑一遍(该重算的重算),再把卡发出去。
|
||||
* 收步那条近道下面也认同一件事 —— 两处共用 `answerSettlesStep`,一处真源。
|
||||
*/
|
||||
const answer = lastCardAnswer(trail);
|
||||
const settled = answerSettlesStep(answer?.chose);
|
||||
const touchedItem = stateRuntime.items.find((i) => i.id === current.item)?.worldTouched === true;
|
||||
if (touchedItem && !settled) {
|
||||
await deps.memory.append({
|
||||
taskId,
|
||||
kind: 'observation',
|
||||
title: `人把这一步打回重做${answer?.chose?.startsWith('custom:') === true ? `:${answer.chose.slice(7).trim()}` : ''}`,
|
||||
detail: {
|
||||
item: current.item,
|
||||
hint: '这一步不算办完 —— 按他的意见重算之后再问他一遍(卡会重新发一张,不是同一张的复读)。',
|
||||
},
|
||||
});
|
||||
}
|
||||
const auto = await autoRunner.run(recipe, {
|
||||
taskId,
|
||||
stepId: current.item,
|
||||
ownerId: (await deps.tasks.of(taskId)).ownerId,
|
||||
humanCallCap: (await deps.tasks.of(taskId)).humanCallCap,
|
||||
quiet: false,
|
||||
carded: stateRuntime.items.find((i) => i.id === current.item)?.worldTouched === true,
|
||||
carded: touchedItem && settled,
|
||||
});
|
||||
if (auto.failedAt !== undefined) {
|
||||
/*
|
||||
@@ -2248,7 +2295,14 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
*/
|
||||
const noDoneWhen = current.doneWhen === undefined || current.doneWhen.trim() === '';
|
||||
const carded = stateRuntime.items.find((i) => i.id === current.item)?.worldTouched === true;
|
||||
if (noDoneWhen && carded && recipeIssuesCard(recipe) && current.guide.trim() === '') {
|
||||
/*
|
||||
* **人签了字才叫办完 —— 他说重做就不是**(2026-08-31,见 `answerSettlesStep` 的头注:
|
||||
* 打回与通过从前在这儿一模一样,于是「重做」当场变成了「推进」)。
|
||||
* 走到这儿说明配方这一拍没能重新发出卡(条件不成立 / 预算见底)——
|
||||
* 那就停在这一步不收,下面那条「配方跑完了,可这一步还没算办完」把话说给人听,
|
||||
* 而不是替他把字签了。
|
||||
*/
|
||||
if (noDoneWhen && carded && settled && recipeIssuesCard(recipe) && current.guide.trim() === '') {
|
||||
const active = readyToWork(parsed, stateRuntime, current.item);
|
||||
const settled = transitionItem(active, current.item, 'Completed');
|
||||
await deps.memory.append({
|
||||
|
||||
@@ -1718,3 +1718,40 @@ B 表却**不在**同一份 `only` 名单里——B 表对应的那一行仍留
|
||||
**排查法留一条**:「配方办不下去」类的 `AutoRecipeError`,若原文提到「已入库/已落地…撤不掉」,
|
||||
先看是不是这条——尤其当那一步的 `project` 动作带 `prune: true`、且它写向的表**也**在
|
||||
某张数据工单的 `gateLedgers` 上时,概率最高。
|
||||
|
||||
---
|
||||
|
||||
## 人答得太快,那条回传被判 `stale`,单子就此没人叫醒(2026-08-31 夜,未修)
|
||||
|
||||
**实测指纹**(e2e w05 案例 0,ACT-WORK-05):
|
||||
|
||||
```
|
||||
23:53:39.778 card-sent 逐条差异结论已备好…(S3 审核卡)
|
||||
23:53:40.048 card-reply 人答了(重做,带意见) ← 卡发出去 270ms 后
|
||||
23:53:40.274 last_progress_at 被更新 ← **发卡那一拍自己**跑完了
|
||||
此后 4 分钟一拍没跑,
|
||||
runtime 停在 awaiting-event
|
||||
```
|
||||
|
||||
后来手工把 `runtime` 拨回 `running`,同一份代码立刻把该走的三拍走完(重做那一轮的新卡也发了)
|
||||
—— 也就是说**逻辑是对的,只是没有任何东西再叫醒它**。
|
||||
|
||||
**病根**:2026-08-27 那条 stale 守卫的前提是
|
||||
「`lastProgressAt > occurredAt` ⇒ 这条回传一定已被那一拍消费(留痕先于事件落)」。
|
||||
人答得比发卡那一拍收尾还快时,这个前提就不成立:**把 `lastProgressAt` 往前挪的那一拍,
|
||||
正是发出这张卡的那一拍** —— 它不可能消费一条当时还不存在的回传。
|
||||
于是两件事叠在一起把单子锁死:
|
||||
- 回传路由的 `wake`(2026-08-25)先把任务拨成 `running`,可**发卡那一拍随后收尾时又把它
|
||||
park 回 `awaiting-event`**(晚到的写覆盖了早到的 wake);
|
||||
- 人事件随后到达,`deliver` 按 `lastProgressAt > occurredAt` 判 stale,「只记到达痕不推」。
|
||||
再没有第三样东西会叫醒它。
|
||||
|
||||
**为什么少见**:真人点一张卡至少要几秒,发卡那一拍早收尾了;e2e 是 3 秒轮询 + 秒答,
|
||||
才把这条窗口撞得出来。但 CLAUDE.md 里几次「怎么推不动了」的报告有可能是同一条。
|
||||
|
||||
**要治**(方向,未验证):park 那一下要认「这一拍开跑之后有没有人叫醒过我」——
|
||||
比如 `wake` 记一个时刻/世代号,tick 收尾时的 `update` 带上 `where last_wake_at <= <本拍开跑时刻>`;
|
||||
或者 tick 收尾前再看一眼「有没有新的未消费回传」,有就留在 `running`。
|
||||
**别去放宽 stale 守卫本身** —— 它治的是另一个真病(晚到十几秒的事件把没人答的卡收掉,
|
||||
2026-08-27 实测),两件事不要混。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user