fix(task): 终态收尾不等蒸馏 —— 终止接口曾被模型调用吊 13-45 秒到报错
CI / ci (push) Failing after 10s

四单法证:finished_at 与蒸馏条目 created_at 差 13.2/36.3/43.4/44.6 秒。
finish 把终态落库并广播之后同步 await memory.distill(一次真模型调用),
终止路由与放行收尾拍(连带推进器租约)都陪着模型等。改后台跑:
封卷仍在前,失败照旧吞掉不掀终态;三测钉死,改前即 30s 超时。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
This commit is contained in:
xcs
2026-09-01 03:36:53 +08:00
co-authored by Claude Fable 5
parent 3b9c559a47
commit 0e02bb9411
4 changed files with 140 additions and 14 deletions
+12
View File
@@ -2026,6 +2026,18 @@
**副产**:e2e 样例 3「原PN缺库」的前提已被污染(`LM2596S-3.3` 现在真在库里,多半是白天 ops 起单 **副产**:e2e 样例 3「原PN缺库」的前提已被污染(`LM2596S-3.3` 现在真在库里,多半是白天 ops 起单
落的主数据),那一例实际走的也是验收工单那条路 —— 名字与实测不符,清库存那件事记在这儿。 落的主数据),那一例实际走的也是验收工单那条路 —— 名字与实测不符,清库存那件事记在这儿。
- **「终止活动」接口被蒸馏吊 13–45 秒到报错:收尾不等模型**(2026-09-01 用户:「点击终止后
接口迟迟不返回,还会报错,但实例确实终止了」):法证在库里 —— 四单被终止的任务,
`finished_at` 与蒸馏条目 `created_at` 差 13.2 / 36.3 / 43.4 / 44.6 秒:`finish` 把终态
落库并广播**之后**,同步 `await` 了 `memory.distill`(整单记忆 + JSON schema 结构化输出的
一次真模型调用),HTTP 响应吊在半空 —— 屏上早显示已终止(SSE 那一刻就发了),响应拖到
任何一环断连(watcher 重启进程 / 代理 / 人重试)就成一次报错。吊着的不止终止接口:
**放行收尾那一拍同样走 `finish`,推进器与任务租约陪模型等 40 秒**。修:蒸馏后台跑
(`void …distill().catch()`),封卷仍在前、失败照旧吞掉不掀终态;三测钉死(悬着的蒸馏不挡
control 返回 / 拒绝不外泄 / seal 在 distill 前),改前第一条即 30 秒超时 —— 变异天然验证。
CONTRACT.md 同步。门禁 exit 0(2590 测);:8787 已换上(watcher 只在重建时拉起、不守护
死进程 —— 杀了要 touch 源码触发重建才回来)。
- **W05 红条可灭:gate_state 开关列**(2026-08-31 用户:「没有必填了红色区域就不要显示」, - **W05 红条可灭:gate_state 开关列**(2026-08-31 用户:「没有必填了红色区域就不要显示」,
`part-substitution-assessment@2.13.0`):病根 —— gate_reason 清空串按列归型不落笔 `part-substitution-assessment@2.13.0`):病根 —— gate_reason 清空串按列归型不落笔
(jsonb 保旧值),红条亮过灭不了。治:案卷加 `gate_state`(tally open/clear,双向真写), (jsonb 保旧值),红条亮过灭不了。治:案卷加 `gate_state`(tally open/clear,双向真写),
+2 -1
View File
@@ -54,5 +54,6 @@
- **已终结不得再推进**:`tick` / `deliver` 对终态任务抛 `TaskAlreadyTerminalError` —— - **已终结不得再推进**:`tick` / `deliver` 对终态任务抛 `TaskAlreadyTerminalError` ——
流级重做一律重开新实例,不复活旧任务 流级重做一律重开新实例,不复活旧任务
- **叫停不是终态**:转挂起待纠偏,并记**最重档纠正信号** - **叫停不是终态**:转挂起待纠偏,并记**最重档纠正信号**
- **封卷触发蒸馏**:终态后 `seal` + `distill`,产出活动记忆候选条目 - **封卷触发蒸馏**:终态后 `seal` + `distill`,产出活动记忆候选条目;蒸馏是一次真模型调用,
**后台跑、不吊着收尾的调用方**(2026-09-01:终止接口曾被它吊 13-45 秒到报错),失败吞掉不掀终态
- **断点续跑靠三份持久状态**:任务记忆 / 草稿层 / 运行时实例状态,缺一即不可续 - **断点续跑靠三份持久状态**:任务记忆 / 草稿层 / 运行时实例状态,缺一即不可续
@@ -0,0 +1,109 @@
/**
* 终态收尾不等蒸馏(2026-09-01 用户实测:运营中心点「终止活动」,接口迟迟不返回最后报错,
* 而实例确实终止了)。
*
* 病根:`finish` 在返回前同步 `await` 了 `memory.distill` —— 那是一次真模型调用
* (整单记忆 + JSON schema 结构化输出,四单法证 13.2s / 36.3s / 43.4s / 44.6s:
* `sys.task.finished_at` 与蒸馏条目 `created_at` 的差就是接口被吊的时长)。
* 终态在它**之前**已落库并广播,所以屏上早就显示已终止,响应却被吊在半空 ——
* 拖到任何一环断连(watcher 重启进程 / 代理 / 人重试)就成一次报错。
* 吊着的不止终止接口:放行收尾那一拍同样走 `finish`,推进器与任务租约陪着蒸馏等。
*
* 蒸馏本来就是**进化管线**的入口、不是终态的一部分(`finish` 里的注释白纸黑字),
* 它的失败早已被容忍 —— 那它的**时长**同样不该由收尾的调用方来付。
*/
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(distill: TaskServiceDeps['memory']['distill']) {
const trail: string[] = [];
const task: Task = {
id: 't-1' as TaskId,
activityId: 'w05',
ownerId: 's-1' as StaffId,
title: 'x',
runtime: 'awaiting-human',
envelope: {},
data: {},
caps: { steps: 40, tokens: 200000, humanCalls: 2, activeMinutes: 60 },
used: { steps: 0, tokens: 0, humanCalls: 0, activeMs: 0 },
startedAt: '2026-09-01T00:00:00.000Z',
};
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 () => void trail.push('seal'),
distill: (input) => {
trail.push('distill');
return distill(input);
},
},
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-09-01T00:00:00.000Z') },
ids: { next: () => 't-x' },
};
return { service: createTaskService(deps), tasks, trail };
}
describe('finish 不把收尾吊在蒸馏上', () => {
it('蒸馏还悬着,terminate 已经返回、终态已落 —— 接口不等模型', async () => {
let release: (() => void) | undefined;
const gate = new Promise<never[]>((resolve) => {
release = () => resolve([]);
});
const h = harness(() => gate as Promise<never[]>);
const outcome = await h.service.control('t-1' as TaskId, 'terminate', 's-1' as StaffId, '手动终止');
expect(outcome.kind).toBe('terminated');
expect(h.tasks.get('t-1')?.terminal).toBe('terminated');
/* 走到这儿 gate 还没放行 —— 蒸馏在后台悬着,control 没有等它 */
expect(h.trail).toEqual(['seal', 'distill']);
release?.();
await gate;
});
it('蒸馏抛了也不掀翻终态,更不外泄成未处理拒绝', async () => {
const h = harness(async () => {
throw new Error('Insufficient Balance');
});
const outcome = await h.service.control('t-1' as TaskId, 'terminate', 's-1' as StaffId, '手动终止');
expect(outcome.kind).toBe('terminated');
expect(h.tasks.get('t-1')?.terminal).toBe('terminated');
/* 让后台那口拒绝走完微任务 —— vitest 对未处理拒绝会当场炸,这里干净跑完即是断言 */
await new Promise((resolve) => setTimeout(resolve, 0));
});
it('封卷在蒸馏之前 —— 只追加不改的守卫先立起来,蒸馏读的是封好的卷', async () => {
const h = harness(async () => []);
await h.service.control('t-1' as TaskId, 'terminate', 's-1' as StaffId, '手动终止');
expect(h.trail).toEqual(['seal', 'distill']);
});
});
@@ -498,23 +498,27 @@ export function createTaskService(deps: TaskServiceDeps): TaskService {
// 终态封卷 → 归档 → 蒸馏钩子产出活动记忆候选条目 // 终态封卷 → 归档 → 蒸馏钩子产出活动记忆候选条目
await deps.memory.seal(taskId); await deps.memory.seal(taskId);
try { /*
await deps.memory.distill({ * 蒸馏**后台跑,不吊着收尾的调用方**(2026-09-01 用户实测:运营中心点「终止活动」,
* 接口迟迟不返回最后报错,而实例确实终止了)。它是一次真模型调用 —— 整单记忆 +
* JSON schema 结构化输出,四单法证 13-45 秒(`finished_at` 与蒸馏条目 `created_at`
* 的差)。终态在上面几行已落库并广播,把响应吊在这儿,拖到任何一环断连
* (进程重启 / 代理 / 人重试)就成一次报错;放行收尾那一拍同样走这里,
* 推进器与任务租约陪着模型等。封卷仍在前(只追加不改的守卫先立起来)。
*
* 失败照旧吞掉:蒸馏是**进化管线**的入口,不是终态的一部分 —— 它产出的是
* 待回放待放行的候选增量,失败(模型不可达等)绝不能把已经落定的终态掀翻。
* 也不能把失败记进任务记忆:此刻记忆**已封卷**,追加会被只追加不改的守卫拒掉
* (那正是它该做的)。蒸馏的成败属于进化管线的账,不属于这一单的过程留痕。
*/
void deps.memory
.distill({
taskId, taskId,
activityId: task.activityId, activityId: task.activityId,
trigger: 'terminal', trigger: 'terminal',
prompt: `任务「${task.title}」以 ${terminal} 收场(${reason})。归纳可复用的事实、模式、裁决与例外。`, prompt: `任务「${task.title}」以 ${terminal} 收场(${reason})。归纳可复用的事实、模式、裁决与例外。`,
}); })
} catch { .catch(() => undefined);
/*
* 蒸馏是**进化管线**的入口,不是终态的一部分:它产出的是待回放待放行的候选增量。
* 它失败(模型不可达等)绝不能把已经落定的终态掀翻 —— 那会让任务卡在
* 「实际已终、状态却没落」的中间态,治理台也看不到。
*
* 也不能把失败记进任务记忆:此刻记忆**已封卷**,追加会被只追加不改的守卫拒掉
* (那正是它该做的)。蒸馏的成败属于进化管线的账,不属于这一单的过程留痕。
*/
}
return updated; return updated;
}, },