mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:31:58 +00:00
驳回不再记成失败(前端):终态按 end_reason 写「被驳回」,副标题写谁、在哪一步、备注
为什么:服务端已把人的驳回收成 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 的地盘)里被驳回的那一次仍写「已手动停止」,没动。
This commit is contained in:
@@ -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 = "服务重启时这次运行仍在排队,没有可恢复的进度,请重新发起运行"
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<StatusCode, StatusMeta> = {
|
||||
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<string, StatusCode> = {
|
||||
* 才分得开——传 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:服务重启打断、没有审批,叫「已挂起 · 可续跑」
|
||||
|
||||
@@ -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 <div className={clsx(line, 'text-faint')} title={run.error ?? undefined}>已取消{reason ? ` · ${reason}` : ''}</div>
|
||||
}
|
||||
case 'rejected':
|
||||
// 人的驳回(end_reason=rejected):报错原文就是给人看的那一句(「「付款」未确认(被驳回),没有发送:…」)
|
||||
return (
|
||||
<div className={clsx(line, 'text-faint')} title={run.error ?? undefined} data-run-reason="rejected">
|
||||
{REJECTED_TEXT.title}{run.error ? ` · ${run.error}` : ''}
|
||||
</div>
|
||||
)
|
||||
default: {
|
||||
const summary = summarizeRun(run.input, run.output)
|
||||
return summary
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -49,8 +49,8 @@ export const SEGMENT_CODES: StatusCode[] = RUN_STATUS_ORDER
|
||||
* 打断后没有审批——要看待审批列表才分得开。approvals 还没加载时传 null,按
|
||||
* 等待审批算(最常见的来源),不会在加载的那一瞬间闪成「已挂起」。
|
||||
*/
|
||||
export function runCode(run: Pick<Run, 'id' | 'status'>, approvals: Approval[] | null): StatusCode {
|
||||
if (run.status !== 'interrupted') return resolveStatus(run.status)
|
||||
export function runCode(run: Pick<Run, 'id' | 'status' | 'end_reason'>, 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),
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<string, any>
|
||||
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<ChatState>((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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user