From 2fa2db3ea013e237e5aed944054f3298ebb6f52a Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Fri, 16 Oct 2026 10:54:02 +0800 Subject: [PATCH] =?UTF-8?q?=E9=A9=B3=E5=9B=9E=E4=B8=8D=E5=86=8D=E8=AE=B0?= =?UTF-8?q?=E6=88=90=E5=A4=B1=E8=B4=A5=EF=BC=88=E5=89=8D=E7=AB=AF=EF=BC=89?= =?UTF-8?q?=EF=BC=9A=E7=BB=88=E6=80=81=E6=8C=89=20end=5Freason=20=E5=86=99?= =?UTF-8?q?=E3=80=8C=E8=A2=AB=E9=A9=B3=E5=9B=9E=E3=80=8D=EF=BC=8C=E5=89=AF?= =?UTF-8?q?=E6=A0=87=E9=A2=98=E5=86=99=E8=B0=81=E3=80=81=E5=9C=A8=E5=93=AA?= =?UTF-8?q?=E4=B8=80=E6=AD=A5=E3=80=81=E5=A4=87=E6=B3=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为什么:服务端已把人的驳回收成 cancelled + end_reason=rejected(上一个提交)。前端以前靠正则解析报错原文认驳回,而且只认 「人工驳回」一种,动作确认、工具调用审批、代码执行审批的三种原话都落到兜底、给出注定再失败的「继续运行」(05-已知遗留 (a))。 做了什么: - types.ts:Run.end_reason(rejected / stopped)。 - lib/status:新增状态 rejected(「被驳回」,同取消的剪影、不画红、终态),resolveStatus 按 endReason 区分;记录页列表、详情、 航迹的终点按它写「被驳回 · 原话」。 - run/decode:run.cancelled 带 reason=rejected 时标题写「被驳回」,副标题「张工 驳回了「付款」:金额不对」(超时驳回写按设置 驳回、不重复备注),指向那一步;手动停止、老事件照旧「已取消」。 - store/chat + ChatPage:终态里被驳回的一轮照 cancelled 收(不给继续运行),徽标写「被驳回」,补救区说明这是审批人的决定、 副标题写谁和备注;刷新之后从 meta.rejected 恢复;核对状态时按运行的 end_reason 认。 - lib/explain:正则只留给存量 failed+驳回,补上「未确认(被驳回)」「的调用已被驳回」「代码执行已被驳回」三种原话,一律不给继续运行。 - 继续运行被拒的说法改成「被驳回的运行不能继续运行:改好内容后重新发起」(check-copy 不收「接着跑」)。 - check-decode 加一节:reason=rejected 的标题、副标题、没署名没备注、超时驳回、相位、手动停止和老事件。 偏离:问数据的「之前的尝试」(retry 留档,W1-a 的地盘)里被驳回的那一次仍写「已手动停止」,没动。 --- backend/app/engine/runner.py | 4 +-- backend/tests/test_run_rejected.py | 2 +- frontend/src/lib/explain.ts | 10 +++++--- frontend/src/lib/status.ts | 23 ++++++++++++++--- frontend/src/lib/terms.ts | 19 ++++++++++++++ frontend/src/pages/ChatPage.tsx | 6 +++-- frontend/src/pages/runs/RunDetailView.tsx | 7 ++++-- frontend/src/pages/runs/RunList.tsx | 10 +++++++- frontend/src/pages/runs/TracePane.tsx | 4 ++- frontend/src/pages/runs/model.ts | 4 +-- frontend/src/run/decode.ts | 8 +++++- frontend/src/store/chat.ts | 30 +++++++++++++++++++---- frontend/src/types.ts | 10 ++++++++ scripts/check-decode.mjs | 27 ++++++++++++++++++++ 14 files changed, 141 insertions(+), 23 deletions(-) diff --git a/backend/app/engine/runner.py b/backend/app/engine/runner.py index 7fcd0a62..25c5e5e1 100644 --- a/backend/app/engine/runner.py +++ b/backend/app/engine/runner.py @@ -50,8 +50,8 @@ RUN_REJECTED = "run_rejected" #: 运行为什么结束在 cancelled(Run.end_reason,docs/design/next-round/00-总方案.md X9) END_REJECTED = "rejected" END_STOPPED = "stopped" -#: 被驳回的运行不能接着跑:从断点重跑会读到同一个驳回答复、再停在同一处 -REJECTED_CONTINUE = "被驳回的运行不能接着跑:改好内容后重新发起" +#: 被驳回的运行不能继续运行:从断点重跑会读到同一个驳回答复、再停在同一处 +REJECTED_CONTINUE = "被驳回的运行不能继续运行:改好内容后重新发起" #: 服务重启时还在排队的运行改成失败时写的话(run.error 和补发的 run.failed 是同一句) QUEUED_AT_RESTART = "服务重启时这次运行仍在排队,没有可恢复的进度,请重新发起运行" diff --git a/backend/tests/test_run_rejected.py b/backend/tests/test_run_rejected.py index 520d2d4f..690db845 100644 --- a/backend/tests/test_run_rejected.py +++ b/backend/tests/test_run_rejected.py @@ -132,7 +132,7 @@ async def test_the_api_carries_end_reason_and_refuses_to_continue_a_rejected_run assert (body["status"], body["end_reason"]) == ("cancelled", "rejected") r = await client.post(f"/api/runs/{run_id}/continue", json={}) assert r.status_code == 409 and r.json()["code"] == "run_rejected" - assert r.json()["detail"] == "被驳回的运行不能接着跑:改好内容后重新发起" + assert r.json()["detail"] == "被驳回的运行不能继续运行:改好内容后重新发起" stop = await client.post(f"/api/runs/{run_id}/cancel") assert stop.status_code == 409 and "被驳回" in stop.json()["detail"] diff --git a/frontend/src/lib/explain.ts b/frontend/src/lib/explain.ts index 4e6e06e5..d9c992fe 100644 --- a/frontend/src/lib/explain.ts +++ b/frontend/src/lib/explain.ts @@ -295,11 +295,15 @@ export function explainRunError(error: string | null | undefined, detail?: strin raw, } } - // 后端有一条路径以前写「人工拒绝:」(现在统一为「人工驳回:」),历史运行里两种都有 - if (/人工(?:驳回|拒绝)/.test(text)) { + // 只留给存量:加 end_reason 之前被驳回的运行记的是 failed,报错原文是这几种说法。新的驳回结束在 cancelled + + // end_reason=rejected,界面按那个字段说(lib/status、REJECTED_TEXT),不走这里(05-已知遗留 (a))。后端有一条路径 + // 以前写「人工拒绝:」(现在统一为「人工驳回:」);动作确认、工具调用审批、代码执行审批的驳回各有一种原话。 + // 接着跑只会读到同一个驳回答复、再停一次,所以都不给「继续运行」 + if (/人工(?:驳回|拒绝)|未确认(被驳回)|的调用已被驳回|代码执行已被驳回/.test(text)) { + const human = /^人工(?:驳回|拒绝)/.test(plain) return { title: '人工审批驳回,运行终止', - reason: plain.replace(/^人工(?:驳回|拒绝)[::]?\s*/, '') || undefined, + reason: (human ? plain.replace(/^人工(?:驳回|拒绝)[::]?\s*/, '') : plain) || undefined, action: '这是审批人的决定,不是故障。如有需要,请修改内容后重新发起运行。', continuable: false, raw, diff --git a/frontend/src/lib/status.ts b/frontend/src/lib/status.ts index c817fbf3..96a6eb16 100644 --- a/frontend/src/lib/status.ts +++ b/frontend/src/lib/status.ts @@ -22,6 +22,7 @@ export type StatusCode = | 'succeeded' // 运行跑完 | 'failed' | 'cancelled' + | 'rejected' // 运行:被人驳回结束(后端是 cancelled + end_reason=rejected,05-已知遗留 (a)) | 'suspended' // 服务重启后挂起,可续跑 | 'held' // 后端的 interrupted 但没有待审批:已挂起 · 可续跑 | 'skipped' // 被 skip_if 跳过 @@ -104,6 +105,11 @@ export const STATUS: Record = { code: 'cancelled', label: '已取消', short: '已取消', shape: 'stop', alert: false, terminal: true, ...tok('cancelled', 'var(--text-faint)'), }, + // 人的决定,不是故障:不画红、不给继续运行;和手动停止同一个剪影,叫法分开 + rejected: { + code: 'rejected', label: '被驳回', short: '被驳回', shape: 'stop', alert: false, terminal: true, + hint: '审批人驳回了这次运行,运行到这里结束;改好内容后重新发起', ...tok('cancelled', 'var(--text-faint)'), + }, suspended: { code: 'suspended', label: '已中断(服务重启)', short: '已中断', shape: 'pause', alert: true, terminal: true, hint: '服务重启时本次运行尚未结束,已在断点处挂起,可继续运行', ...tok('suspended', 'var(--warn)'), @@ -141,15 +147,25 @@ const ALIAS: Record = { * 才分得开——传 pendingApproval。不知道时(没传)按等待审批处理,这是 * interrupted 最常见的来源,也是改造前的说法,不会比原来更错。 */ -export function resolveStatus(status: string | null | undefined, opts?: { pendingApproval?: boolean }): StatusCode { +export function resolveStatus(status: string | null | undefined, opts?: StatusOpts): StatusCode { if (!status) return 'idle' if (status === 'interrupted') return opts?.pendingApproval === false ? 'held' : 'waiting' + // 被驳回只看服务端给的 end_reason,不解析报错原文(界面层不放逻辑) + if (status === 'cancelled' && opts?.endReason === 'rejected') return 'rejected' if (status in STATUS) return status as StatusCode return ALIAS[status] ?? 'idle' } +/** + * 落状态要的旁证:pendingApproval 分 interrupted 的两种;endReason 是运行的 end_reason(cancelled 时分被驳回和手动停止) + */ +export interface StatusOpts { + pendingApproval?: boolean + endReason?: string | null +} + /** 状态的完整外观。未知状态码回落到 idle 的外观,文字写原码,免得静默吞掉 */ -export function statusMeta(status: string | null | undefined, opts?: { pendingApproval?: boolean }): StatusMeta { +export function statusMeta(status: string | null | undefined, opts?: StatusOpts): StatusMeta { const code = resolveStatus(status, opts) const meta = STATUS[code] if (status && code === 'idle' && status !== 'idle' && !(status in ALIAS)) { @@ -159,7 +175,7 @@ export function statusMeta(status: string | null | undefined, opts?: { pendingAp } /** 只要文字 */ -export function statusLabel(status: string | null | undefined, opts?: { pendingApproval?: boolean; short?: boolean }): string { +export function statusLabel(status: string | null | undefined, opts?: StatusOpts & { short?: boolean }): string { const meta = statusMeta(status, opts) return opts?.short ? meta.short : meta.label } @@ -186,6 +202,7 @@ export function serverStatusOf(code: StatusCode): RunStatus | null { case 'waiting': case 'held': case 'suspended': return 'interrupted' case 'done': case 'succeeded': return 'succeeded' case 'queued': case 'running': case 'failed': case 'cancelled': return code + case 'rejected': return 'cancelled' default: return null } } diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index 51fb6dba..72bd4212 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -5135,6 +5135,25 @@ export const ORIGIN_TEXT = { actor: (who: string, verified: boolean) => `${verified ? '已登录用户' : '署名'}「${who}」`, } +/** + * 驳回 ≠ 失败(docs/design/next-round/05-已知遗留.md (a)):人的驳回让运行结束在 cancelled + end_reason=rejected, + * run.cancelled 带着在哪一步(label)、谁(actor)、结论(decision)、备注(note)。时间线、问数据的终态、记录页都照这一份说, + * 不再按报错原文猜 + */ +export const REJECTED_TEXT = { + title: '被驳回', + /** 副标题:谁在哪一步驳回、备注;超时驳回写按设置驳回(备注是系统写的那句,不重复) */ + sub: (d: { actor?: unknown; label?: unknown; note?: unknown; decision?: unknown }): string => { + const who = typeof d.actor === 'string' ? d.actor.trim() : '' + const where = typeof d.label === 'string' && d.label.trim() ? `「${d.label.trim()}」` : '这一步' + const note = typeof d.note === 'string' ? d.note.trim() : '' + if (d.decision === 'expired_reject') return `${where}超过时限,按设置驳回` + return `${who ? `${who} 驳回了${where}` : `${where}被驳回`}${note ? `:${note}` : ''}` + }, + /** 问数据:这一轮的运行被驳回结束时,补救区的那一句 */ + chatReason: '这一轮的运行被审批人驳回,已经结束。这是人的决定,不是故障;改好内容后可以重新运行。', +} + /** 来源标签:老运行没有来源时写「来源未记录」 */ export const originLabel = (kind: string | null | undefined): string => kind && kind in ORIGIN_LABEL ? ORIGIN_LABEL[kind as OriginKind] : ORIGIN_TEXT.unknown diff --git a/frontend/src/pages/ChatPage.tsx b/frontend/src/pages/ChatPage.tsx index 8bae6511..a93803b7 100644 --- a/frontend/src/pages/ChatPage.tsx +++ b/frontend/src/pages/ChatPage.tsx @@ -17,7 +17,7 @@ import { humanizeError } from '../lib/errors' import { explainRunError } from '../lib/explain' import { canLeave, leavePass } from '../lib/leave' import { formatNumber, formatTime } from '../lib/format' -import { CHAT_BLUEPRINT_TEXT } from '../lib/terms' +import { CHAT_BLUEPRINT_TEXT, REJECTED_TEXT } from '../lib/terms' import { useCatalog, useCatalogListState, useDatasources, useOnReconnect, type CatalogListState } from '../store/catalog' import { useAtLeast } from '../store/auth' import { useBlueprint } from '../store/blueprint' @@ -383,7 +383,8 @@ function toStreamTurn(turn: ChatTurn): StreamTurn { : turn.phase === 'error' ? 'error' : live || turn.phase === 'checking' ? 'running' : 'done', // 没核对上的不知道结局,用「未运行」那个空心点,不画成完成也不画成失败 - statusCode: turn.phase === 'cancelled' ? 'cancelled' + // 被人驳回的(turn.rejected,服务端 end_reason=rejected)照 lib/status 写「被驳回」,不写「已取消」 + statusCode: turn.phase === 'cancelled' ? (turn.rejected ? 'rejected' : 'cancelled') : turn.phase === 'suspended' ? 'suspended' // 助手反问:这一轮结束了,在等用户回答 : turn.asked && turn.phase === 'done' ? 'waiting' @@ -729,6 +730,7 @@ function Remedies({ conversationId, turnId, last, onRephrase, readOnly }: { // 中断、取消、没核对上在流里不出红框(它们不是故障),为什么停、停了意味着什么在这里说 const note = kind === 'suspended' || kind === 'unknown' ? turn.failure + : kind === 'cancelled' && turn.rejected ? { title: '', reason: REJECTED_TEXT.chatReason, hint: turn.rejected.sub } : kind === 'cancelled' ? { title: '', reason: turn.run ? '你已停止本轮,对应的运行也已一并取消。' : '你在搭建工作流时停止了本轮。' } : null diff --git a/frontend/src/pages/runs/RunDetailView.tsx b/frontend/src/pages/runs/RunDetailView.tsx index 5769acb3..74073354 100644 --- a/frontend/src/pages/runs/RunDetailView.tsx +++ b/frontend/src/pages/runs/RunDetailView.tsx @@ -205,7 +205,7 @@ export function RunDetailView({ runId, onChange, onDeleted }: { const aimed = focus && !goneFromCanvas(focus) ? focus : null const canvasHref = hrefFor(aimed) // 还在往前走的叫「打开」(接着看实时的、去处理),停下的叫「回放」 - const replayable = code === 'succeeded' || code === 'failed' || code === 'cancelled' + const replayable = code === 'succeeded' || code === 'failed' || code === 'cancelled' || code === 'rejected' const dup = !!run.workflow_id && duplicateNames(workflows.map((w) => ({ id: w.id, name: w.name }))).has(run.workflow_name) // ---- 动作 ---- @@ -730,7 +730,10 @@ export function RunDetailView({ runId, onChange, onDeleted }: { /** 事件之外的事实(查到的状态、有没有审批)和事件推出的相位合成一个显示码 */ function displayCode(run: Run, phase: RunPhase, pending: boolean | undefined): StatusCode { switch (phase) { - case 'running': case 'queued': case 'waiting': case 'succeeded': case 'failed': case 'cancelled': + case 'cancelled': + // 被驳回只看服务端的 end_reason(05-已知遗留 (a)) + return run.end_reason === 'rejected' ? 'rejected' : 'cancelled' + case 'running': case 'queued': case 'waiting': case 'succeeded': case 'failed': return phase case 'suspended': // 后端写的是 interrupted:服务重启打断、没有审批,叫「已挂起 · 可续跑」 diff --git a/frontend/src/pages/runs/RunList.tsx b/frontend/src/pages/runs/RunList.tsx index 01e9e699..0958caf6 100644 --- a/frontend/src/pages/runs/RunList.tsx +++ b/frontend/src/pages/runs/RunList.tsx @@ -7,7 +7,8 @@ import { formatCost, formatDateTime, formatDay, formatSpan, formatTime, formatTokens, parseServerTime, shortId, } from '../../lib/format' import { - ORIGIN_LABEL, ORIGIN_TEXT, RUN_CLASS_LABEL, TASK_KIND_LABEL, TASK_LOG_TEXT, UNSAVED_HINT, UNSAVED_NAME, isUnsaved, originLabel, + ORIGIN_LABEL, ORIGIN_TEXT, REJECTED_TEXT, RUN_CLASS_LABEL, TASK_KIND_LABEL, TASK_LOG_TEXT, UNSAVED_HINT, UNSAVED_NAME, isUnsaved, + originLabel, runName, } from '../../lib/terms' import { summarizeRun } from '../../run/decode' @@ -466,6 +467,13 @@ function RowContext({ run, code, approval, now }: { const reason = cancelReason(run.error) return
已取消{reason ? ` · ${reason}` : ''}
} + case 'rejected': + // 人的驳回(end_reason=rejected):报错原文就是给人看的那一句(「「付款」未确认(被驳回),没有发送:…」) + return ( +
+ {REJECTED_TEXT.title}{run.error ? ` · ${run.error}` : ''} +
+ ) default: { const summary = summarizeRun(run.input, run.output) return summary diff --git a/frontend/src/pages/runs/TracePane.tsx b/frontend/src/pages/runs/TracePane.tsx index 8c6523a0..73dbbbdb 100644 --- a/frontend/src/pages/runs/TracePane.tsx +++ b/frontend/src/pages/runs/TracePane.tsx @@ -11,7 +11,7 @@ import { explainRunError } from '../../lib/explain' import { NONE, formatCost, formatDateTime, formatNumber, formatOffset, formatSpan, formatTokens, shortId, } from '../../lib/format' -import { COST_TEXT, runClassLabel, nodeTypeLabel, runCostText } from '../../lib/terms' +import { COST_TEXT, REJECTED_TEXT, runClassLabel, nodeTypeLabel, runCostText } from '../../lib/terms' import { topology, type GraphLike } from '../../run/derive' import { isActivePhase, isSettled, liveAt, project, type NodeState, type Projection, type RunPhase, type Trace, @@ -449,6 +449,7 @@ function momentsOf( const failed = trace.phase === 'failed' ? trace.failedNodeId : undefined const why = trace.phase === 'failed' ? explainRunError((failed && trace.nodes[failed]?.error) || error).title + : code === 'rejected' ? error?.trim() || undefined : trace.phase === 'cancelled' ? cancelReason(error) ?? undefined : trace.phase === 'suspended' ? error?.trim() || undefined : undefined out.push({ @@ -458,6 +459,7 @@ function momentsOf( text: trace.phase === 'succeeded' ? '运行完成' : failed ? `失败于${name(failed)}` : trace.phase === 'failed' ? '运行失败' + : code === 'rejected' ? REJECTED_TEXT.title : trace.phase === 'cancelled' ? '运行取消' : '运行挂起', sub: why, }) diff --git a/frontend/src/pages/runs/model.ts b/frontend/src/pages/runs/model.ts index 61674490..9236665c 100644 --- a/frontend/src/pages/runs/model.ts +++ b/frontend/src/pages/runs/model.ts @@ -49,8 +49,8 @@ export const SEGMENT_CODES: StatusCode[] = RUN_STATUS_ORDER * 打断后没有审批——要看待审批列表才分得开。approvals 还没加载时传 null,按 * 等待审批算(最常见的来源),不会在加载的那一瞬间闪成「已挂起」。 */ -export function runCode(run: Pick, approvals: Approval[] | null): StatusCode { - if (run.status !== 'interrupted') return resolveStatus(run.status) +export function runCode(run: Pick, approvals: Approval[] | null): StatusCode { + if (run.status !== 'interrupted') return resolveStatus(run.status, { endReason: run.end_reason }) return resolveStatus(run.status, { pendingApproval: approvals == null ? undefined : hasPendingApproval(approvals, run.id), }) diff --git a/frontend/src/run/decode.ts b/frontend/src/run/decode.ts index 427e24bc..185c3d52 100644 --- a/frontend/src/run/decode.ts +++ b/frontend/src/run/decode.ts @@ -6,7 +6,7 @@ import { ABSENCE_CLAIM_TEXT, ACTION_CONFIRM_TEXT, ACTION_ENV_LABEL, ACTION_LOG_TEXT, CATALOG_DRIFT_TEXT, CATALOG_PATCH_TEXT, COPILOT_CONTEXT_TEXT, JUDGE_TEXT, LOOK_TEXT, MERGE_TEXT, ORIGIN_TEXT, RUN_SQL_CHECK_TEXT, SQL_CHECK_TEXT, TASK_KIND_LABEL, TASK_LOG_TEXT, TASK_STATUS_LABEL, TASK_TEXT, TYPE_LABEL, originLabel, CATALOG_TABLE_FIELD_LABEL, CHALLENGE_TEXT, DUAL_CHECK_TEXT, INQUIRY_TEXT, JUDGE_TRUNCATED_TEXT, MASKED_CELL_TEXT, NODE_TYPE_LABEL, - VERIFY_TRUNCATED_TEXT, BP_OP_TEXT, TEMPLATE_LABEL, bpPathText, FILE_TEXT, + VERIFY_TRUNCATED_TEXT, BP_OP_TEXT, TEMPLATE_LABEL, bpPathText, FILE_TEXT, REJECTED_TEXT, claimTally, evidenceTally, issuanceLabel, nodeTypeLabel, } from '../lib/terms' @@ -2673,6 +2673,12 @@ export function decodeRun(events: RunEvent[], final?: RunFinal, opts?: DecodeOpt // 用户主动停下的,不是出错:收成中性的"已取消",不画红色的失败。谁停的、放弃等 // 审批时一并关了几条,写在副标题上——多人用时「怎么没了」得有个交代 settle(endingOf(event, awaiting)!) + if (d.reason === 'rejected') { + // 人的驳回(05-已知遗留 (a)):同样不是出错,标题写「被驳回」,副标题写谁在哪一步驳回、备注 + out.push({ id: `rc-${seq}`, seq, kind: 'lifecycle', status: 'cancelled', title: REJECTED_TEXT.title, + sub: REJECTED_TEXT.sub(d), ...(d.node_id ? { nodeId: String(d.node_id) } : {}) }) + break + } // 说法和画布胶囊的那一句一致:「张工 放弃了这次运行,1 条待审批一并关闭」 const who = typeof d.actor === 'string' && d.actor.trim() ? d.actor.trim() : '' const said = String(d.message ?? '').trim() diff --git a/frontend/src/store/chat.ts b/frontend/src/store/chat.ts index 6ed4255c..7983e5bf 100644 --- a/frontend/src/store/chat.ts +++ b/frontend/src/store/chat.ts @@ -2,7 +2,7 @@ import { create } from 'zustand' import { ApiError, api, streamCopilot, streamRun } from '../api/client' import type { CopilotOp, FixKind, RunFinal } from '../run/decode' import { copilotContext, copilotLook, decodeRun, looksOf } from '../run/decode' -import { CHAT_BLUEPRINT_TEXT, LOOK_TEXT } from '../lib/terms' +import { CHAT_BLUEPRINT_TEXT, LOOK_TEXT, REJECTED_TEXT } from '../lib/terms' import type { LookEvent } from '../typesApp' import { errorMessage, humanizeError, isNetworkError } from '../lib/errors' import { guardReview, hasEvidence } from '../lib/evidence' @@ -87,6 +87,11 @@ export interface Failure { fixFirst?: true } +/** 被驳回的那一句:事件流里来的按 run.cancelled 的谁、哪一步、备注拼(REJECTED_TEXT.sub);核对状态时只有报错原文 */ +export interface Rejected { + sub: string +} + /** 限定这一问只查哪些数据源。名字跟着存:库删了之后,这一轮查的是谁还得说得出来 */ export interface ScopeSource { id: string @@ -127,6 +132,8 @@ export interface TurnMeta { issuance?: Record noQuery?: boolean outcome?: 'cancelled' | 'suspended' + /** 运行被人驳回结束(outcome 是 cancelled):副标题那一句,刷新之后照样写「被驳回」,不退回成「你已停止本轮」 */ + rejected?: Rejected failure?: Failure attempts?: Attempt[] /** 这一轮从开问到交付的墙钟毫秒 */ @@ -180,6 +187,11 @@ export interface ChatTurn { /** 一行错误文案(兼容老数据和流式头部)。结构化的在 failure 里 */ error: string failure?: Failure | null + /** + * 这一轮的运行被人驳回结束(服务端 cancelled + end_reason=rejected,05-已知遗留 (a))。phase 照旧是 cancelled(不是故障、 + * 不给继续运行),说法另写「被驳回」和谁、在哪一步、备注 + */ + rejected?: Rejected | null startedAt: number /** 交付、失败、取消或中断的时刻。计时器停在这里 */ endedAt?: number @@ -440,6 +452,7 @@ function buildMeta(t: ChatTurn): TurnMeta { issuance: slimIssuance(t.output?._issuance ?? t.rawOutput?._issuance) ?? t.meta?.issuance, noQuery: t.noQuery || undefined, outcome: t.phase === 'cancelled' ? 'cancelled' : t.phase === 'suspended' ? 'suspended' : undefined, + rejected: t.phase === 'cancelled' && t.rejected ? t.rejected : undefined, failure: t.failure ?? undefined, attempts: t.attempts?.length ? t.attempts : undefined, ms: t.endedAt ? t.endedAt - t.startedAt : t.meta?.ms, @@ -678,6 +691,7 @@ function restoreTurn(t: ConversationTurn): ChatTurn { output, error: phase === 'error' ? (failure ? lineOf(failure) : t.status === 'running' ? buildInterrupted.title : '') : '', failure: phase === 'error' ? (t.status === 'running' ? buildInterrupted : failure) : phase === 'suspended' ? SUSPENDED : null, + rejected: phase === 'cancelled' ? meta?.rejected ?? null : null, startedAt: Date.parse(t.created_at ?? '') || Date.now(), endedAt: meta?.ms != null ? (Date.parse(t.created_at ?? '') || 0) + meta.ms : undefined, lastSeq: 0, @@ -1043,7 +1057,10 @@ export const useChat = create((set, get) => ({ } if (run.status === 'cancelled') { if (cur.phase !== 'done' && cur.phase !== 'cancelled') { - conclude({ phase: 'cancelled', status: PHASE_TEXT.cancelled, failure: null, error: '' }, '已取消') + // 被驳回只看服务端的 end_reason:核对时手上只有运行记录,副标题用报错原文(它就是给人看的那句) + const rejected = run.end_reason === 'rejected' ? { sub: run.error || REJECTED_TEXT.title } : null + conclude({ phase: 'cancelled', status: rejected ? REJECTED_TEXT.title : PHASE_TEXT.cancelled, failure: null, + error: '', rejected }, '已取消') } return } @@ -1673,15 +1690,18 @@ function watch( // 服务在关停:这一轮会停在断点上。停在审批上的仍然是在等人 if (d.code === 'server_shutdown' && current()?.phase !== 'waiting') suspend() break - case 'run.cancelled': + case 'run.cancelled': { ended = true batch.now() + // 人的驳回(reason=rejected)也收成 cancelled:不是故障、不给继续运行,说法写「被驳回」和谁、哪一步、备注 + const rejected = d.reason === 'rejected' ? { sub: REJECTED_TEXT.sub(d) } : null patch(() => ({ - phase: 'cancelled', status: PHASE_TEXT.cancelled, cancel: null, endedAt: Date.now(), - runStatus: 'cancelled', final: { status: 'cancelled' }, + phase: 'cancelled', status: rejected ? REJECTED_TEXT.title : PHASE_TEXT.cancelled, cancel: null, + endedAt: Date.now(), runStatus: 'cancelled', final: { status: 'cancelled' }, rejected, })) void flow.persist({ status: 'error', error: '已取消' }) break + } case 'run.failed': { ended = true batch.now() diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 0bd204f6..8381edd5 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -4471,3 +4471,13 @@ export interface DataSourceName { kind?: string enabled?: boolean } + +// --------------------------------------------------------------------------- +// 驳回 ≠ 失败(docs/design/next-round/05-已知遗留.md (a),总方案 X9) + +/** 运行为什么结束在 cancelled(服务端 Run.end_reason):rejected 人工驳回,stopped 手动停止。其余终态、老运行为空 */ +export type RunEndReason = 'rejected' | 'stopped' + +export interface Run { + end_reason?: RunEndReason | null +} diff --git a/scripts/check-decode.mjs b/scripts/check-decode.mjs index 98dd2172..f079d849 100644 --- a/scripts/check-decode.mjs +++ b/scripts/check-decode.mjs @@ -2724,5 +2724,32 @@ await section('读入附件 file.extracted:读入多少字、全文多少字 && odd[0]?.title === '读入附件' && odd[2]?.title === '附件抽不出文字', odd.map((s) => `${s.title}|${s.sub ?? ''}`).join(' ; ')) }) +await section('人的驳回 run.cancelled reason=rejected(05-已知遗留 (a)):终态是「被驳回」,副标题写谁、哪一步、备注;手动停止照旧「已取消」', async () => { + const E = (seq, type, node_id, data = {}) => ({ seq, type, node_id, ts: seq, data }) + const head = [E(1, 'run.started', null, { nodes: 2 }), E(2, 'node.started', 'pay', { label: '付款' }), + E(3, 'human.requested', 'pay', { title: '确认发送:付款' }), E(4, 'human.resolved', 'pay', { approved: false, note: '金额不对', actor: '张工' }), + E(5, 'node.failed', 'pay', { error: '「付款」未确认(被驳回),没有发送:金额不对' })] + const ended = (data) => flatten(mod.decodeRun([...head, E(6, 'run.cancelled', null, data)])) + .find((s) => s.kind === 'lifecycle' && s.id === 'rc-6') + const rejected = ended({ reason: 'rejected', node_id: 'pay', label: '付款', kind: 'action_confirm', decision: 'reject', + actor: '张工', note: '金额不对' }) + check('标题写「被驳回」,不写「已取消」「失败」', rejected?.title === '被驳回' && rejected?.status === 'cancelled', + `${rejected?.title} / ${rejected?.status}`) + check('副标题写谁在哪一步驳回、备注', rejected?.sub === '张工 驳回了「付款」:金额不对', rejected?.sub) + check('指向驳回的那一步(点了画布取景过去)', rejected?.nodeId === 'pay', rejected?.nodeId) + const anonymous = ended({ reason: 'rejected', node_id: 'pay', label: '付款', decision: 'reject', actor: null, note: '' }) + check('没署名、没备注:「「付款」被驳回」,不出 null、undefined', anonymous?.sub === '「付款」被驳回', anonymous?.sub) + const expired = ended({ reason: 'rejected', node_id: 'pay', label: '付款', decision: 'expired_reject', actor: null, + note: '超过时限,按设置驳回' }) + check('超时驳回:写按设置驳回,备注不重复', expired?.sub === '「付款」超过时限,按设置驳回', expired?.sub) + const phase = mod.decodePhase([...head, E(6, 'run.cancelled', null, { reason: 'rejected' })]) + check('相位收成 cancelled(不是 failed、不给继续运行)', phase === 'cancelled', phase) + const stopped = ended({ reason: 'stopped', actor: '李工' }) + check('手动停止照旧「已取消」,写谁停的', stopped?.title === '已取消' && stopped?.sub === '李工 取消了本次运行', + `${stopped?.title} / ${stopped?.sub}`) + const legacy = ended({}) + check('老事件(没有 reason)照旧「已取消」', legacy?.title === '已取消', legacy?.title) +}) + console.log(failed ? `\n✗ ${failed} 项未通过` : '\n✓ 解码器全部通过') process.exit(failed ? 1 : 0)