diff --git a/CLAUDE.md b/CLAUDE.md index 39dbe72..a37ddf6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2026,6 +2026,18 @@ **副产**: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 用户:「没有必填了红色区域就不要显示」, `part-substitution-assessment@2.13.0`):病根 —— gate_reason 清空串按列归型不落笔 (jsonb 保旧值),红条亮过灭不了。治:案卷加 `gate_state`(tally open/clear,双向真写), diff --git a/apps/api/src/modules/task/CONTRACT.md b/apps/api/src/modules/task/CONTRACT.md index d9a0145..105fcb0 100644 --- a/apps/api/src/modules/task/CONTRACT.md +++ b/apps/api/src/modules/task/CONTRACT.md @@ -54,5 +54,6 @@ - **已终结不得再推进**:`tick` / `deliver` 对终态任务抛 `TaskAlreadyTerminalError` —— 流级重做一律重开新实例,不复活旧任务 - **叫停不是终态**:转挂起待纠偏,并记**最重档纠正信号** - - **封卷触发蒸馏**:终态后 `seal` + `distill`,产出活动记忆候选条目 + - **封卷触发蒸馏**:终态后 `seal` + `distill`,产出活动记忆候选条目;蒸馏是一次真模型调用, + **后台跑、不吊着收尾的调用方**(2026-09-01:终止接口曾被它吊 13-45 秒到报错),失败吞掉不掀终态 - **断点续跑靠三份持久状态**:任务记忆 / 草稿层 / 运行时实例状态,缺一即不可续 diff --git a/apps/api/src/modules/task/__tests__/finish-not-hostage.test.ts b/apps/api/src/modules/task/__tests__/finish-not-hostage.test.ts new file mode 100644 index 0000000..ad4e21a --- /dev/null +++ b/apps/api/src/modules/task/__tests__/finish-not-hostage.test.ts @@ -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([[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((resolve) => { + release = () => resolve([]); + }); + const h = harness(() => gate as Promise); + + 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']); + }); +}); diff --git a/apps/api/src/modules/task/internal/usecase/task-service.ts b/apps/api/src/modules/task/internal/usecase/task-service.ts index 5dce2d1..d6c4164 100644 --- a/apps/api/src/modules/task/internal/usecase/task-service.ts +++ b/apps/api/src/modules/task/internal/usecase/task-service.ts @@ -498,23 +498,27 @@ export function createTaskService(deps: TaskServiceDeps): TaskService { // 终态封卷 → 归档 → 蒸馏钩子产出活动记忆候选条目 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, activityId: task.activityId, trigger: 'terminal', prompt: `任务「${task.title}」以 ${terminal} 收场(${reason})。归纳可复用的事实、模式、裁决与例外。`, - }); - } catch { - /* - * 蒸馏是**进化管线**的入口,不是终态的一部分:它产出的是待回放待放行的候选增量。 - * 它失败(模型不可达等)绝不能把已经落定的终态掀翻 —— 那会让任务卡在 - * 「实际已终、状态却没落」的中间态,治理台也看不到。 - * - * 也不能把失败记进任务记忆:此刻记忆**已封卷**,追加会被只追加不改的守卫拒掉 - * (那正是它该做的)。蒸馏的成败属于进化管线的账,不属于这一单的过程留痕。 - */ - } + }) + .catch(() => undefined); return updated; },