From 2265ab7833cd1e98e596e00103d2441545fd8e18 Mon Sep 17 00:00:00 2001 From: xcs Date: Mon, 31 Aug 2026 11:16:09 +0800 Subject: [PATCH] =?UTF-8?q?fix(desk):=20=E4=BF=9D=E5=AD=98=E9=82=A3?= =?UTF-8?q?=E4=B8=80=E6=8B=8D=E5=AF=B9=E8=AF=9D=E5=8C=BA=E4=B9=9F=E5=88=AB?= =?UTF-8?q?=E5=87=BA=E5=A3=B0=20=E2=80=94=E2=80=94=20=E3=80=8C=E5=9C=A8?= =?UTF-8?q?=E5=8A=9E=E4=BA=8B=E3=80=8D=E4=B8=8D=E4=BA=AE=E3=80=81say=20?= =?UTF-8?q?=E8=90=BD=E5=B7=A5=E4=BD=9C=E6=97=A5=E5=BF=97=E4=B8=8D=E4=B8=8A?= =?UTF-8?q?=E5=AF=B9=E8=AF=9D=E5=8C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户:「每次保存的时候右边也会出现 ai 推进中,然后会说一句话,能不能把这个也取消掉」 两处,同一个根:件上按保存会 wake 这一单(「保存即铺格」),runtime 被拨成 running。 ① 右边那条办事条:`TaskChat` 的 `working` 只看 runtime === 'running'。它自己的注释 早写着「等人、等外部、已收尾都不算 —— 那时亮『工作中』就是撒谎:环没在转,是在等」—— save-wake 恰恰把「在等人」的单拨成了 running,把这条原则架空了。加 `!awaitingHuman(...)` 是**恢复**那句注释说的语义:有未办结工单 / 未回的卡就是在等人,哪怕 runtime 写着 running。 判断与工作区那一侧同一个函数。 ② 那句话:quiet 拍只跑 project 与 say,于是人按几次保存,同一句交代往对话区说几遍。 对话区的纪律是**一轮一句,不是一拍一句**(2026-08-01):只有交出控制权那一拍的交代 摆到人面前;而 quiet 拍压根没交出控制权(人正对着卡填),算不上新一轮。 改:quiet 拍的 say 标 `internal`。压下去不是丢掉 —— `internal` 是两个投影共用的一个旗 (终审 I5/F2):`chat-projection` 据此挡住对话区,`task-log.machinery()` 对 `detail.auto` 开了例外,这一笔在工作日志里照旧看得见(话有插值时值的变化也查得到)。 测:quiet 的 say 标 internal 且工作日志仍在(3 条变异各红一条 —— 退回原样 / 反过来连 非 quiet 也标 / 连 auto 一起去掉;其中「反过来标」红的是既有那条「非 quiet 不标」, 两条互为护栏)。后一半(标了 internal 就进不了对话区)由 chat-projection 既有测钉着, projectChat 没出模块边界,两半各钉在自己模块里、注释互指。 pnpm gates 退出码 0(2494 测)。 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet --- .../loop-engine/__tests__/auto-runner.test.ts | 32 +++++++++++++++++++ .../internal/usecase/auto-runner.ts | 20 ++++++++++-- .../features/desk-task/internal/TaskChat.tsx | 15 ++++++++- 3 files changed, 64 insertions(+), 3 deletions(-) diff --git a/apps/api/src/modules/loop-engine/__tests__/auto-runner.test.ts b/apps/api/src/modules/loop-engine/__tests__/auto-runner.test.ts index 89e5490..2f6a73c 100644 --- a/apps/api/src/modules/loop-engine/__tests__/auto-runner.test.ts +++ b/apps/api/src/modules/loop-engine/__tests__/auto-runner.test.ts @@ -465,6 +465,38 @@ describe('配方留痕真的到得了工作日志(spec §5.9 验收 · R10)', () expect(appended[0]?.detail?.['internal']).toBeUndefined(); }); + /** + * **人正在件上填的那几拍(quiet),say 不上对话区**(2026-08-31 用户:「每次保存的时候 + * 右边也会出现 ai 推进中,然后会说一句话,能不能把这个也取消掉」)。 + * + * 件上按保存会 `wake` 这一单(「保存即铺格」),配方跟着跑一趟 quiet —— 而 quiet 只跑 + * project 与 say,于是人按几次保存,同一句交代就往对话区说几遍。对话区的纪律是 + * **一轮一句,不是一拍一句**(2026-08-01),而 quiet 拍压根没有交出控制权(人正对着 + * 那张卡填),算不上新一轮。 + * + * 压下去不是丢掉:`internal` 是两个投影共用的一个旗(终审 I5/F2)。这一条钉住的是 + * **前一半**(quiet → 标 internal,而工作日志按 `detail.auto` 照旧放行); + * **后一半**(标了 internal 就进不了对话区)钉在 + * `apps/api/src/modules/interaction/__tests__/chat-projection.test.ts` + * 「引擎内部观察(internal 标)进日志不进对话」那一条 —— `projectChat` 没出模块边界, + * 跨模块深入 internal 会被 depcruise 拦,两半各钉在自己模块里。 + */ + it('**quiet 拍的 say:标 internal(对话区据此挡住),而工作日志照旧看得见**', async () => { + const { runner, appended, ctx } = harness({ n: 2 }); + await runner.run( + { do: [{ kind: 'say', text: { cat: ['已核对 ', { in: 'n' }, ' 颗料'] } }] }, + { ...ctx, quiet: true, carded: true }, + ); + expect(appended).toHaveLength(1); + expect(appended[0]?.detail?.['internal']).toBe(true); + /* 话本身没丢:`detail.auto` 那条例外让它留在工作日志里(值有插值时也查得到) */ + expect(appended[0]?.title).toBe('已核对 2 颗料'); + expect(typeof appended[0]?.detail?.['auto']).toBe('string'); + const lines = structuredLog([asLogEntry(appended[0]!)]); + expect(lines).toHaveLength(1); + expect(lines[0]?.who).toBe('system'); + }); + it('project 那一笔:进日志且归系统,**而且标着 internal(对话区那一侧据此挡住)**', async () => { const { runner, appended, ctx } = harness({}); await runner.run( diff --git a/apps/api/src/modules/loop-engine/internal/usecase/auto-runner.ts b/apps/api/src/modules/loop-engine/internal/usecase/auto-runner.ts index 4b89375..bee203c 100644 --- a/apps/api/src/modules/loop-engine/internal/usecase/auto-runner.ts +++ b/apps/api/src/modules/loop-engine/internal/usecase/auto-runner.ts @@ -125,12 +125,24 @@ export function createAutoRunner(deps: AutoRunnerDeps): { * 而 `result` 不在 `task-log.ts` 的 `SKELETON` 里,这一笔永远进不了工作日志 * (spec §5.9 承诺「配方每一笔照常进 task_memory,工作日志里归系统灰」落空)。 * `observation` 在骨架里;不带 `detail.say` 时 `actorOf` 本就归系统。 + * + * **人正在件上填的那几拍(quiet),这句话不上对话区**(2026-08-31 用户: + * 「每次保存的时候右边也会出现 ai 推进中,然后会说一句话,能不能把这个也取消掉」)。 + * 对话区的纪律是**一轮一句,不是一拍一句**(2026-08-01):只有**交出控制权** + * 那一拍的交代摆到人面前。而 quiet 拍恰恰没有交出控制权 —— 控制权本来就在 + * 人手上(他正对着这张件填),配方这一趟只是按最新草稿把行铺好;人按几次保存 + * 就跑几趟,同一句话跟着说几遍,那是纯噪音。 + * 压下去不是丢掉:`internal` 是两个投影共用的一个旗(终审 I5/F2)—— + * `chat-projection` 据此挡住对话区,而 `task-log.machinery()` 对 `detail.auto` + * 开了例外,这一笔在**工作日志里照旧看得见**(话有插值时值的变化也查得到)。 */ await deps.memory.append({ taskId: context.taskId, kind: 'observation', title: evalText(action.text, base), - detail: { auto: `${context.stepId}#${index + 1}` }, + detail: context.quiet + ? { auto: `${context.stepId}#${index + 1}`, internal: true } + : { auto: `${context.stepId}#${index + 1}` }, }); return { wrote: 0, dropped: 0 }; } @@ -384,7 +396,11 @@ export function createAutoRunner(deps: AutoRunnerDeps): { } const base: ExprCtx = { scope, lets, condition }; if (action.when !== undefined && !condition(action.when, { ...scope, ...lets })) continue; - /* 有卡在等人交回:只算、只写 —— 不发新卡、不 submit、不 analyze */ + /* + * 有卡在等人交回:只算、只写 —— 不发新卡、不 submit、不 analyze。 + * `say` 照跑但**落成 internal**(见 runAction 里 say 那一段):话要留痕, + * 只是不摆到对话区上去 —— 人正在填,这一趟没有新一轮可言。 + */ if (ctx.quiet && action.kind !== 'project' && action.kind !== 'say') continue; const done = await runAction(action, index, base, ctx); diff --git a/apps/web/src/features/desk-task/internal/TaskChat.tsx b/apps/web/src/features/desk-task/internal/TaskChat.tsx index 9a64415..162352b 100644 --- a/apps/web/src/features/desk-task/internal/TaskChat.tsx +++ b/apps/web/src/features/desk-task/internal/TaskChat.tsx @@ -14,6 +14,7 @@ import { call } from '@web-shared/typed-fetch.ts'; import { Ico } from '@web-shared/Ico.tsx'; import { EmptyState } from '@web-shared/desk/module-kit.tsx'; import { ChatStream, WorkingStrip } from './chat-bubbles.tsx'; +import { awaitingHuman, pendingCardsOf } from './pending-cards.ts'; import { Btn } from '@web-shared/Btn.tsx'; export function TaskChat({ @@ -43,8 +44,20 @@ export function TaskChat({ * AI 正在办这单 = Loop 在自己转(runtime running)。 * 等人(awaiting-human)、等外部(awaiting-event)、已收尾都不算 —— * 那时亮「工作中」就是撒谎:环没在转,是在等。 + * + * **`&& !awaitingHuman(...)` 是 2026-08-31 补的**(用户:「每次保存的时候右边也会 + * 出现 ai 推进中」):件上按保存会 `wake` 这一单(「保存即铺格」,不叫醒参数格永远 + * 不出现)—— runtime 就此被拨成 `running`,而人其实正对着卡在填。上面这三句话说的 + * 原则没变,变的是「等人」这件事从此不只看 runtime:**有未办结的工单 / 未回的卡, + * 就是在等人**,哪怕 runtime 写着 running。工作区那一侧同一个判断、同一个函数。 */ - const working = detail.card.terminal === undefined && detail.card.runtime === 'running'; + const working = + detail.card.terminal === undefined && + detail.card.runtime === 'running' && + !awaitingHuman({ + openOrders: detail.workOrders.filter((o) => o.done === undefined).length, + cards: pendingCardsOf(detail.chat), + }); /** 回传后一律重取任务投影 —— 卡与工单的状态由服务端说,前端不自行推演 */ const refresh = () => {