This repository has been archived on 2026-09-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
BPOS-TMP-REPO/apps/api/src/modules/task/__tests__/deliver-stale.test.ts
T
xcsandClaude Fable 5 8314148ebc fix(w05): 重判的行由确定的一侧圈 —— 打回点名工作温度,不许去改静态电流
用户实测:「我打回让修改工作温度的结论,给我改了静态电流的结论」。库里的证据一字不差:
工作温度它没改(还给了理由:候选值 1℃ 远窄于 -40~+85℃,硬约束不满足 —— 这半截是对的),
而没人问过的静态电流被它从 soft_mismatch 翻成 match,evidence_note 里却写着「维持原判」。

根因不是提示词不够严,是**整表交出去、整表写回来**:没有任何东西限制它只动被点名的那一行,
而「我只改了那一条」这句话本身也出自它嘴里。规范 §4.6 纪律一(行由确定的那一侧决定,
不由 AI 决定)—— 昨天刚加的重判轮自己栽在这条上。

S3 复核轮两笔变四笔(全部只在 case_redo_note != "" 那一轮跑):
① 圈范围:按字面点名(contains,与基座给模型摆的「意见点名对上了哪几行」同一种认法)标
   named / kept(明细表新增 redo_scope 列);
② 兜底:一条都没点到(意见说的是整批)→ 整表算被点名,读上一笔刚写下的 tally,不重算;
③ 重判只喂 named 的行 —— 没点名的行 AI 连见都见不到,自然改不着(也省 token);
④ 写回也只写这几行;模型漏答某一条时 else 兜回它上一轮的值。

同批修一条挡了两次路的老毛病(昨夜刚记 debt,今晚又撞停、还卡住了我自己的验证):
人答得比发卡那一拍收尾还快时,回传被判 stale、wake 又被那一拍的 park 覆盖,单子停在
awaiting-event 再没人叫醒。deliver 判了 stale 照旧不跑这一拍,但把停着的单子拨回 running。

验收:e2e w05 5/5 released、被拒 0;案例 0 新断言「打回只动了被点名的那几条,其余行一个字没变」
真跑通过(途中逼出脚本自己的坑:放行会清空草稿层,快照得留在放行之前);
模型调用 案例 0 = 2 次、其余 = 1 次。四处变异验证。W05@2.18.0 已装库。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-09-01 01:17:09 +08:00

143 lines
6.9 KiB
TypeScript

/**
* 迟到的人事件不再推一拍(2026-08-27,用户:「你叫了人我都没填你就直接推进了」)。
*
* 回传落库那一刻回传口已 wake、推进器随即跑了那一拍(读留痕里的 card-reply,不等事件);
* 人事件从队列里投过来晚十几秒,那时上一拍早已发出下一张卡 —— 再 tick 一拍,模型会把没人答的
* 那一步收掉。判法只有一个数:这一单在这条回传之后推进过没有(lastProgressAt > occurredAt)。
*/
import { describe, expect, it } from 'vitest';
import type { StaffId, TaskId } from '@contracts';
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(lastProgressAt: Date | undefined, runtime: Task['runtime'] = 'awaiting-human') {
const task: Task = {
id: 't-1' as TaskId,
activityId: 'w05',
ownerId: 's-1' as StaffId,
title: 'x',
runtime,
envelope: {},
data: {},
caps: { steps: 40, tokens: 200000, humanCalls: 4, activeMinutes: 60 },
used: { steps: 0, tokens: 0, humanCalls: 0, activeMs: 0 },
startedAt: '2026-08-27T09:40:00.000Z',
};
const tasks = new Map<string, Task>([[task.id, task]]);
const appended: Array<{ kind: string; title: string; detail?: Record<string, unknown> }> = [];
let advanced = 0;
let onEvents = 0;
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 () => lastProgressAt,
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 () => {
advanced += 1;
return { status: 'awaiting' };
},
onEvent: async () => {
onEvents += 1;
return { status: 'running' };
},
},
memory: {
append: async (entry) => void appended.push({ kind: entry.kind, title: entry.title, ...(entry.detail === undefined ? {} : { detail: entry.detail }) }),
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 () => undefined },
intake: { askCompletion: async () => undefined },
comms: { record: async () => undefined },
clock: { now: () => new Date('2026-08-27T09:48:30.000Z') },
ids: { next: () => 't-x' },
};
return { service: createTaskService(deps), appended, advanced: () => advanced, onEvents: () => onEvents };
}
const REPLY_AT = '2026-08-27T09:47:48.000Z';
describe('迟到的人事件', () => {
it('回传之后这一单已推进过 → 只记一条到达痕(标 stale),不 onEvent、不推一拍', async () => {
const h = harness(new Date('2026-08-27T09:48:00.200Z'));
const out = await h.service.deliver('t-1' as TaskId, { kind: 'human', ref: 'card-p10', payload: {}, occurredAt: REPLY_AT });
expect(out.progressed).toBe(false);
expect(h.advanced()).toBe(0);
expect(h.onEvents()).toBe(0);
const arrival = h.appended.find((e) => e.title === '事件到达(human):card-p10');
expect(arrival?.detail?.['stale']).toBe(true);
});
/*
* **判了 stale 还得管它有没有人叫醒**(2026-09-01,两次实测撞停):卡发出 270ms 后人就答了 ——
* 把 `lastProgressAt` 往前挪的那一拍,**正是发出这张卡的那一拍**,它不可能消费一条当时还不存在的回传;
* 而回传口的 `wake` 也白做了(那一拍随后收尾,把任务 park 回 `awaiting-event`,晚到的写盖了早到的)。
* 于是卡答完了、没有新卡在等、单子停着,**再没有第三样东西会叫醒它** —— 实测停了 15 分钟,
* 手工把 runtime 拨回 running,同一份代码立刻把该走的几拍走完。
* 这一拍照旧不跑(stale 的本意不变),但停着的单子要拨回 running 交给推进器。
*/
it('**stale 但单子停着** → 这一拍照旧不跑,可要把它拨回 running,不然再没人叫醒它', async () => {
const h = harness(new Date('2026-08-27T09:48:00.200Z'));
const out = await h.service.deliver('t-1' as TaskId, { kind: 'human', ref: 'card-p10', payload: {}, occurredAt: REPLY_AT });
expect(h.advanced()).toBe(0);
expect(h.onEvents()).toBe(0);
expect(out.task.runtime).toBe('running');
});
it('stale 而单子本来就在跑 → 一个字不动(推进器自己会跑到它)', async () => {
const h = harness(new Date('2026-08-27T09:48:00.200Z'), 'running');
const out = await h.service.deliver('t-1' as TaskId, { kind: 'human', ref: 'card-p10', payload: {}, occurredAt: REPLY_AT });
expect(out.task.runtime).toBe('running');
expect(h.advanced()).toBe(0);
});
it('回传之后没推进过 → 照常投递并推一拍(这就是叫醒它的那一下)', async () => {
const h = harness(new Date('2026-08-27T09:47:40.000Z'));
await h.service.deliver('t-1' as TaskId, { kind: 'human', ref: 'card-s1', payload: {}, occurredAt: REPLY_AT });
expect(h.onEvents()).toBe(1);
expect(h.advanced()).toBe(1);
});
it('放行裁决即使迟到也要投:它只走事件这一条路,拍里读不到它', async () => {
const h = harness(new Date('2026-08-27T09:48:00.000Z'));
const out = await h.service.deliver('t-1' as TaskId, {
kind: 'human',
ref: 'card-acc',
payload: { mode: 'acceptance', verdict: 'pass', values: {} },
occurredAt: REPLY_AT,
});
expect(h.onEvents()).toBe(1);
expect(h.advanced()).toBe(1);
expect(out.progressed).toBe(true);
const arrival = h.appended.find((e) => e.kind === 'observation' && e.title.startsWith('事件到达'));
expect(arrival?.detail?.['stale']).toBeUndefined();
});
it('没带 occurredAt(老调用方)/ 不是人事件 → 行为照旧', async () => {
const h = harness(new Date('2026-08-27T09:48:00.200Z'));
await h.service.deliver('t-1' as TaskId, { kind: 'human', ref: 'card-x', payload: {} });
await h.service.deliver('t-1' as TaskId, { kind: 'data', ref: 'row-1', payload: {}, occurredAt: REPLY_AT });
expect(h.advanced()).toBe(2);
});
});