From f46ab4672e96cd6f7855d97a72d7fe818e3a28e4 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 21 Sep 2026 02:47:59 +0000 Subject: [PATCH 1/4] docs(ops): claim task run error transparency fix Record the NETWORK_ERROR misreport and defer HTTP access logs so the failure reason is no longer hidden behind a network fallback. Co-authored-by: Cursor --- .doc/ops/CURRENT.md | 3 +- .../2026-09-21-http-request-access-log.md | 29 ++++++++++ .../2026-09-21-taskrun-error-transparency.md | 54 +++++++++++++++++++ 3 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 .doc/ops/long-term/2026-09-21-http-request-access-log.md create mode 100644 .doc/ops/short-term/2026-09-21-taskrun-error-transparency.md diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index 55ad5753..b1a0d752 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -5,7 +5,7 @@ ## 焦点 -1. **ProjectTracePanel 空追踪崩溃**:`fix/trace-panel-null-crash-2026-09-21` 修复 `index`/`recheck_marks` 的 JSON `null`(见 [short-term](./short-term/2026-09-21-trace-panel-null-crash.md))。 +1. **任务错误透传**:`fix/taskrun-error-transparency-2026-09-21` 修复 `RunRecord` PascalCase 与前端 `NETWORK_ERROR` 误报(见 [short-term](./short-term/2026-09-21-taskrun-error-transparency.md))。合入需等追踪面板冒烟槽位释放。 2. **阶段回溯与影响闭环已合入 dev**:T1–T3 与 B1–B3 在 `dev`。dev 已 rebuild,`/health` 的 `schema_version` 为 59,五表已在开发库。test/main 未晋升。不改 `production_ready`,不签 D7 对话 ADR。 3. **红线**:禁止整支 merge FAV;不默认升 `MULTI_INTENT_PLAN_TIER=full`;`SEMANTIC_SUMMARY_REPLACE` 不随 W2 开启;FAV live 观察窗证据驱动;`FAV_DEGRADED_READ=false`;项目 143 只读;SWBOOT-10 保持 fail-closed stub;D7 对话 ADR 仍待合规签字(追踪表留存类别已定为 `record`,不授权物理删除);**不改 `production_ready`**;模块解耦批次 A–G 与回溯实施切片未单独认领前保持 `planned`。 @@ -13,6 +13,7 @@ | owner | task | branch | worktree | mutex | status | |-------|------|--------|----------|-------|--------| +| Cursor | 2026-09-21-taskrun-error-transparency | fix/taskrun-error-transparency-2026-09-21 | - | chat+web | in_progress | | Cursor | 2026-09-21-trace-panel-null-crash | fix/trace-panel-null-crash-2026-09-21 | - | trace-index+web | in_progress | | Cursor-Auto | 2026-09-20-stage-backtrack-design | feature/stage-backtrack-design | - | trace-impact-design | done | | Cursor-Auto | 2026-09-20-impact-loop-t1 至 T3/B1–B3 | feature/stage-backtrack-impl | - | trace-index | done | diff --git a/.doc/ops/long-term/2026-09-21-http-request-access-log.md b/.doc/ops/long-term/2026-09-21-http-request-access-log.md new file mode 100644 index 00000000..1ed40c19 --- /dev/null +++ b/.doc/ops/long-term/2026-09-21-http-request-access-log.md @@ -0,0 +1,29 @@ +--- +title: "HTTP 请求访问日志" +status: planned +created: 2026-09-21 +updated: 2026-09-21 +--- + +# HTTP 请求访问日志 + +## 动机 + +2026-09-21 排查 test 环境「网络连接异常」时,app 容器没有请求级访问日志。无法区分「请求未到达」与「到达后在鉴权/SSE 早期失败」。数据库里也没有失败请求的消息行。 + +## 成功标准 + +- 每条 HTTP 请求打一行结构化日志:方法、路径、状态码、耗时、request_id;不记录正文、token、密码 +- 默认在 dev/test 开启;不把密钥或完整 prompt 写入日志 + +## 依赖 + +- 与本次错误透传修复独立,可单独认领 + +## 暂不启动原因 + +本次只修错误透传(json 标签 + 前端文案)。访问日志会碰到所有路由,需要单独设计和验证,避免日志泄漏。 + +## 相关链接 + +- Short-term: [2026-09-21-taskrun-error-transparency.md](../short-term/2026-09-21-taskrun-error-transparency.md) diff --git a/.doc/ops/short-term/2026-09-21-taskrun-error-transparency.md b/.doc/ops/short-term/2026-09-21-taskrun-error-transparency.md new file mode 100644 index 00000000..eac9d2bd --- /dev/null +++ b/.doc/ops/short-term/2026-09-21-taskrun-error-transparency.md @@ -0,0 +1,54 @@ +--- +title: "任务错误透传:NETWORK_ERROR 误报" +status: in_progress +created: 2026-09-21 +updated: 2026-09-21 +owner: Cursor +branch: fix/taskrun-error-transparency-2026-09-21 +worktree: "-" +mutex: chat+web +--- + +# 任务错误透传:NETWORK_ERROR 误报 + +## 目标 + +任务失败或 SSE 断线后,界面展示后端真实原因(或明确的业务拒绝),不再把未知失败统一显示为「网络连接异常」。 + +## 范围 + +- `internal/taskrun/types.go`:`RunRecord` 补 snake_case json 标签,使 `GET /api/v1/tasks/runs/{id}` 与前端 `status` / `error_message` 对齐 +- `web/src/hooks/useTaskRunAttempts.ts`:`failed` 透传 `error_message`;`cancelled` 单独文案;扩展业务码;未知 HTTP 4xx 不再伪装成网络错误 +- 回归测试(Go + 前端) + +## 非目标 + +- 不晋升、不重建 test 环境(随下次 dev→test 生效) +- 不改 natfrp 隧道 +- 不在本次加请求访问日志(见 long-term) + +## 步骤 + +1. [x] 从 `dev` 检出 `fix/taskrun-error-transparency-2026-09-21` +2. [ ] `RunRecord` json 标签 + 序列化回归测试 +3. [ ] 前端错误透传与用例 +4. [ ] 占集成槽位合入 `dev` 并冒烟(槽位空闲后) +5. [ ] 验收报告 + +## 验收标准 + +- [ ] `GET /api/v1/tasks/runs/{id}` JSON 含 `status`、`error_message`、`run_id`,不含 `Status` +- [ ] 任务 `failed` 且有 `error_message` 时,气泡 code 为 `TASK_FAILED`,不是 `NETWORK_ERROR` +- [ ] `blocked_pending_governance` 仍走治理提示 +- [ ] 未知 4xx 业务码不显示「请检查网络」 + +## 相关链接 + +- 根因:test 环境今晨 natfrp 中继中断是诱因;前端 fallback 与 PascalCase 响应使真实原因不可见 +- Long-term: [2026-09-21-http-request-access-log.md](../long-term/2026-09-21-http-request-access-log.md) +- Test report: `.doc/test-reports/2026-09-21-taskrun-error-transparency-test-report.md` + +## 证据 / 备注 + +- 2026-09-21 实测 test `GET /api/v1/tasks/runs/run_1789882153312866054` 返回 `"Status"` / `"ErrorMessage"`(无 json 标签) +- 前端文案「请检查网络后重试」来自 `useTaskRunAttempts.ts` 的 `buildNetworkErrorInfo`,不是 `classify.go` From c10206512de1fcfbfd040d5d2f1d48e342640666 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 21 Sep 2026 03:05:49 +0000 Subject: [PATCH 2/4] fix(taskrun): serialize RunRecord with snake_case json tags The task run API was emitting PascalCase fields, so the client never saw status or error_message and treated every disconnect as a network error. Co-authored-by: Cursor --- internal/taskrun/types.go | 42 ++++++++++++++++++++------------------- 1 file changed, 22 insertions(+), 20 deletions(-) diff --git a/internal/taskrun/types.go b/internal/taskrun/types.go index 6a2a3c83..b7a7ab1e 100644 --- a/internal/taskrun/types.go +++ b/internal/taskrun/types.go @@ -24,27 +24,29 @@ type CreateRequest struct { } // RunRecord 运行实例状态。 +// JSON 键与前端约定及 task_runs 列名一致;缺标签时 encoding/json 会输出 PascalCase, +// 前端读 status/error_message 会得到 undefined,断线恢复分支全部失效。 type RunRecord struct { - RunID string - SessionID string - ProjectID int64 - UserID int64 - ClientTurnID string - IdempotencyKey string // 新 run 稳定键;旧行保持 NULL 不参与唯一索引 - TaskKey string - Stage string - WorkflowInstanceID int64 - StageInstanceID int64 - WorkflowStageKey string - Status string // pending | running | completed | failed | cancelled | pending_author_confirmation | blocked_pending_governance | … - WorkspaceID string - AssistantMsgID int64 - WorkerID string - LeaseExpiresAt *time.Time - CreatedAt time.Time - UpdatedAt time.Time - ErrorMessage string - Attempt int + RunID string `json:"run_id"` + SessionID string `json:"session_id"` + ProjectID int64 `json:"project_id"` + UserID int64 `json:"user_id"` + ClientTurnID string `json:"client_turn_id"` + IdempotencyKey string `json:"idempotency_key"` + TaskKey string `json:"task_key"` + Stage string `json:"stage"` + WorkflowInstanceID int64 `json:"workflow_instance_id"` + StageInstanceID int64 `json:"stage_instance_id"` + WorkflowStageKey string `json:"workflow_stage_key"` + Status string `json:"status"` // pending | running | completed | failed | cancelled | pending_author_confirmation | blocked_pending_governance | … + WorkspaceID string `json:"workspace_id"` + AssistantMsgID int64 `json:"assistant_message_id"` + WorkerID string `json:"worker_id"` + LeaseExpiresAt *time.Time `json:"lease_expires_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + ErrorMessage string `json:"error_message"` + Attempt int `json:"attempt"` } // BuildTaskRunIdempotencyKey 新创建用稳定幂等键;空 client_turn 不设键。 From fbcb78cf92a05d887dc8d2dd248869601fa7ff2c Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 21 Sep 2026 03:05:49 +0000 Subject: [PATCH 3/4] test(server): assert snake_case task run response Lock GET /api/v1/tasks/runs/{id} to run_id, status, and error_message so PascalCase cannot regress. Co-authored-by: Cursor --- internal/server/task_run_json_test.go | 109 ++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 internal/server/task_run_json_test.go diff --git a/internal/server/task_run_json_test.go b/internal/server/task_run_json_test.go new file mode 100644 index 00000000..db368eca --- /dev/null +++ b/internal/server/task_run_json_test.go @@ -0,0 +1,109 @@ +package server + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "chat-agent-system/internal/bizdev" + "chat-agent-system/internal/config" + "chat-agent-system/internal/taskrun" +) + +func TestTaskRunResponseMarshalUsesSnakeCase(t *testing.T) { + raw, err := json.Marshal(taskRunResponse{ + RunRecord: taskrun.RunRecord{ + RunID: "run_snake", + Status: "failed", + ErrorMessage: "dial tcp connection refused", + TaskKey: "user_requirements", + WorkspaceID: "ws_1", + }, + Resumable: false, + }) + if err != nil { + t.Fatal(err) + } + var body map[string]any + if err := json.Unmarshal(raw, &body); err != nil { + t.Fatal(err) + } + for _, key := range []string{"run_id", "status", "error_message", "workspace_id", "task_key", "resumable"} { + if _, ok := body[key]; !ok { + t.Fatalf("missing %q in %s", key, raw) + } + } + for _, pascal := range []string{"Status", "ErrorMessage", "RunID", "WorkspaceID"} { + if _, ok := body[pascal]; ok { + t.Fatalf("pascal key %q leaked into %s", pascal, raw) + } + } + if body["status"] != "failed" || body["error_message"] != "dial tcp connection refused" { + t.Fatalf("payload=%s", raw) + } +} + +func TestGetTaskRunJSONUsesSnakeCase(t *testing.T) { + srv := newTestServer(t) + ts := httptest.NewServer(srv.Routes()) + t.Cleanup(ts.Close) + + runID := "run_snake_http" + rec := &taskrun.RunRecord{ + RunID: runID, + SessionID: "sess-snake", + Status: "failed", + ErrorMessage: "dial tcp connection refused", + TaskKey: "intake", + UserID: 1, + } + if _, err := srv.taskRuns.CreateAndStart( + t.Context(), + runID, + rec, + bizdev.TaskRunRequest{TaskKey: "intake", ProjectName: "snake-json"}, + bizdev.StageRunRequest{}, + "", + ); err != nil { + t.Fatalf("CreateAndStart: %v", err) + } + + token := testBearerToken(t, ts.URL, &config.Config{ + AdminUsername: "admin", + AdminPassword: "admin-test-pass", + }) + req, err := http.NewRequest(http.MethodGet, ts.URL+"/api/v1/tasks/runs/"+runID, nil) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Authorization", "Bearer "+token) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + payload, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + t.Fatalf("GET status=%d body=%s", resp.StatusCode, payload) + } + var body map[string]any + if err := json.Unmarshal(payload, &body); err != nil { + t.Fatalf("decode: %v body=%s", err, payload) + } + status, _ := body["status"].(string) + if status == "" { + t.Fatalf("status key missing or empty (pascal leak?): %s", payload) + } + if _, ok := body["Status"]; ok || strings.Contains(string(payload), `"ErrorMessage"`) { + t.Fatalf("pascal keys in GET body: %s", payload) + } + if _, ok := body["error_message"]; !ok { + t.Fatalf("missing error_message: %s", payload) + } + if _, ok := body["run_id"]; !ok { + t.Fatalf("missing run_id: %s", payload) + } +} From 943a07b2a4720b246e6dfbda2785edcd61482309 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 21 Sep 2026 03:05:49 +0000 Subject: [PATCH 4/4] fix(web): surface real task failure reason instead of NETWORK_ERROR fallback Show error_message for failed runs, a distinct cancelled state, and business 4xx codes instead of a generic network message. Co-authored-by: Cursor --- web/src/hooks/useTaskRunAttempts.test.ts | 49 ++++++++ web/src/hooks/useTaskRunAttempts.ts | 110 +++++------------- web/src/lib/taskRunErrorInfo.ts | 138 +++++++++++++++++++++++ 3 files changed, 217 insertions(+), 80 deletions(-) create mode 100644 web/src/lib/taskRunErrorInfo.ts diff --git a/web/src/hooks/useTaskRunAttempts.test.ts b/web/src/hooks/useTaskRunAttempts.test.ts index 7c4e1e0a..41fd3709 100644 --- a/web/src/hooks/useTaskRunAttempts.test.ts +++ b/web/src/hooks/useTaskRunAttempts.test.ts @@ -1,6 +1,12 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import test from 'node:test'; +import { isBlockedGovernanceStatus } from '../lib/blockedGovernanceCopy'; +import { + buildFailedTaskErrorInfo, + buildNetworkErrorInfo, + readRemoteTaskRun, +} from '../lib/taskRunErrorInfo'; test('chat and task attempts live in one hook', () => { const app = readFileSync(new URL('../App.tsx', import.meta.url), 'utf8'); @@ -12,4 +18,47 @@ test('chat and task attempts live in one hook', () => { assert.equal(hook.includes('createTaskRun'), true); assert.equal(hook.includes('shouldSubscribeExistingRun'), true); assert.equal(hook.includes('禁止无条件 setRunId(null)'), true); + assert.equal(hook.includes('buildFailedTaskErrorInfo'), true); + assert.equal(hook.includes('buildCancelledTaskErrorInfo'), true); }); + +test('failed run with error_message surfaces TASK_FAILED not NETWORK_ERROR', () => { + const info = buildFailedTaskErrorInfo('pi remote runner call failed: connection refused'); + assert.equal(info.code, 'TASK_FAILED'); + assert.equal(info.class, 'fatal'); + assert.equal(info.userMessage.includes('connection refused'), true); + assert.equal(info.userMessage.includes('网络连接异常'), false); + assert.deepEqual(info.actions, ['retry', 'restart']); +}); + +test('snake_case blocked_pending_governance is governance, not a network error', () => { + const hook = readFileSync(new URL('./useTaskRunAttempts.ts', import.meta.url), 'utf8'); + const governanceAt = hook.indexOf('isBlockedGovernanceStatus(view.status)'); + const failedAt = hook.indexOf('failOrRetry(buildFailedTaskErrorInfo'); + assert.ok(governanceAt > 0 && failedAt > governanceAt); + + const view = readRemoteTaskRun({ status: 'blocked_pending_governance', workspace_id: 'ws-1' }); + assert.equal(isBlockedGovernanceStatus(view.status), true); + assert.equal(view.workspaceId, 'ws-1'); + const legacy = readRemoteTaskRun({ Status: 'blocked_pending_governance', WorkspaceID: 'ws-legacy' }); + assert.equal(isBlockedGovernanceStatus(legacy.status), true); + assert.equal(legacy.workspaceId, 'ws-legacy'); +}); + +test('unknown 4xx business code is not reported as a network error', () => { + const info = buildNetworkErrorInfo(409, { errorCode: 'DOC_TASK_DISABLED', detail: '该文档任务未启用' }); + assert.equal(info.code, 'DOC_TASK_DISABLED'); + assert.equal(info.class, 'fatal'); + assert.equal(info.userMessage.includes('网络'), false); + + const unknown = buildNetworkErrorInfo(422, { errorCode: 'SOME_UNKNOWN', detail: '字段不合法' }); + assert.equal(unknown.code, 'SOME_UNKNOWN'); + assert.equal(unknown.userMessage, '字段不合法'); + assert.equal(unknown.userMessage.includes('请检查网络'), false); + + const bare = buildNetworkErrorInfo(400, { errorCode: 'TASK_SCOPE_UNRESOLVED' }); + assert.equal(bare.code, 'TASK_SCOPE_UNRESOLVED'); + assert.match(bare.userMessage, /请求失败/); + assert.equal(bare.userMessage.includes('请检查网络'), false); +}); + diff --git a/web/src/hooks/useTaskRunAttempts.ts b/web/src/hooks/useTaskRunAttempts.ts index c10d337c..24dc60f3 100644 --- a/web/src/hooks/useTaskRunAttempts.ts +++ b/web/src/hooks/useTaskRunAttempts.ts @@ -1,7 +1,6 @@ import type { Dispatch, MutableRefObject, SetStateAction } from 'react'; import { autoRetryDelayMs, - fallbackErrorClass, shouldAutoRetryChat, shouldAutoRetryTask, } from '../api/chatRetry'; @@ -25,6 +24,12 @@ import { pollUntilPostTaskServerDoc, shouldSwitchTemplateSlotToServer, } from '../lib/postTaskDeliverableSync'; +import { + buildCancelledTaskErrorInfo, + buildFailedTaskErrorInfo, + buildNetworkErrorInfo, + readRemoteTaskRun, +} from '../lib/taskRunErrorInfo'; import { isRemoteTaskTerminal, nextAttemptOpts, @@ -105,68 +110,6 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) { applyControlledInvalidationPlan, } = input; - const buildNetworkErrorInfo = ( - httpStatus?: number, - opts?: { errorCode?: string; detail?: string }, - ): ChatErrorInfo => { - const code = opts?.errorCode || ''; - const detail = opts?.detail || ''; - if (code === 'DOC_BUSY' || code === 'document_busy') { - return { - userMessage: detail || '该文档正在被其他用户或项目助手编辑,当前请求未创建新任务', - code: 'DOC_BUSY', - class: 'fatal', - actions: [], - }; - } - if (code === 'DOC_TASK_NOT_IN_ACTIVE_STAGE') { - return { - userMessage: detail || '该文档任务不在当前活动阶段,请先完成当前阶段工作流后再试', - code, - class: 'transient', - actions: ['retry'], - }; - } - if (code === 'DOC_DUTY_FORBIDDEN') { - return { - userMessage: detail || '当前账号不是该文档任务的职责人,无法执行', - code, - class: 'fatal', - actions: ['retry'], - }; - } - if (httpStatus === 401) { - return { - userMessage: '登录已失效,请重新登录后再试', - code: 'ERR_UNAUTHENTICATED', - class: 'fatal', - actions: ['retry'], - }; - } - if (httpStatus === 403) { - if (code === 'ERR_FORBIDDEN' || code === 'AUTHZ') { - return { - userMessage: detail || '无权访问该项目,请确认登录账号与项目成员身份', - code: code || 'ERR_FORBIDDEN', - class: 'fatal', - actions: ['retry'], - }; - } - return { - userMessage: detail || '无权执行该操作,请确认账号权限与工作流阶段', - code: code || 'ERR_FORBIDDEN', - class: 'fatal', - actions: ['retry'], - }; - } - return { - userMessage: '网络连接异常,请检查网络后重试', - code: 'NETWORK_ERROR', - class: fallbackErrorClass({ network: true }), - actions: ['retry'], - }; - }; - // 驱动一次真实后端 SSE 流式对话尝试:气泡状态机 pending → streaming_status → completed/failed/cancelled。 // 闲聊路径在无 client_turn_id 幂等前禁止自动重放 POST;失败仅展示手动「重试」。 type StreamAttemptOpts = { @@ -567,8 +510,9 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) { let preferredDocId: string | null = null; if (finishedRunId) { const remote = await getTaskRun(finishedRunId).catch(() => null); - if (remote?.status) { - const mapped = mapTaskStatusToMessageStatus(remote.status, 'assistant'); + const view = readRemoteTaskRun(remote); + if (view.status) { + const mapped = mapTaskStatusToMessageStatus(view.status, 'assistant'); if (mapped) nextStatus = mapped; } } @@ -605,20 +549,21 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) { }); if (attemptIndex > 0 && existingRunId) { const remote = await getTaskRun(existingRunId).catch(() => null); - if (remote && shouldSubscribeExistingRun(remote.status)) { + const view = readRemoteTaskRun(remote); + if (remote && shouldSubscribeExistingRun(view.status)) { retainedRunId = existingRunId; taskRunStream.setRunId(sessionId, existingRunId); patchAsst({ taskRunId: existingRunId, livePhaseLabel: '正在重连任务…' }); await subscribeTaskRunEvents(existingRunId, { onEvent }, controller.signal); return; } - if (remote && isRemoteTaskTerminal(remote.status)) { + if (remote && isRemoteTaskTerminal(view.status)) { settled = true; - const mapped = mapTaskStatusToMessageStatus(remote.status, 'assistant') || 'failed'; + const mapped = mapTaskStatusToMessageStatus(view.status, 'assistant') || 'failed'; patchAsst({ status: mapped, livePhaseLabel: undefined, - taskWorkspaceId: remote.workspace_id, + taskWorkspaceId: view.workspaceId, taskRunId: existingRunId, }); taskRunStream.setThinking(sessionId, false); @@ -671,47 +616,48 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) { if (runId) { try { const remote = await getTaskRun(runId); - if (remote.status === 'quality_failed') { + const view = readRemoteTaskRun(remote); + if (view.status === 'quality_failed') { settled = true; patchAsst({ status: 'quality_failed', livePhaseLabel: undefined, - taskWorkspaceId: remote.workspace_id, + taskWorkspaceId: view.workspaceId, taskRunId: runId, }); taskRunStream.setThinking(sessionId, false); return; } - if (isBlockedGovernanceStatus(remote.status)) { + if (isBlockedGovernanceStatus(view.status)) { settled = true; patchAsst({ status: 'blocked_pending_governance', content: GOVERNANCE_BLOCKED_HINT, livePhaseLabel: undefined, - taskWorkspaceId: remote.workspace_id, + taskWorkspaceId: view.workspaceId, taskRunId: runId, }); taskRunStream.setThinking(sessionId, false); return; } - if (remote.status === 'completed') { + if (view.status === 'completed') { settled = true; patchAsst({ status: 'completed', livePhaseLabel: undefined, - taskWorkspaceId: remote.workspace_id, + taskWorkspaceId: view.workspaceId, taskRunId: runId, }); taskRunStream.setThinking(sessionId, false); await syncControlledDocsAfterTask(task.taskKey || 'intake'); return; } - if (remote.status === 'pending_author_confirmation') { + if (view.status === 'pending_author_confirmation') { settled = true; patchAsst({ status: 'pending_author_confirmation', livePhaseLabel: undefined, - taskWorkspaceId: remote.workspace_id, + taskWorkspaceId: view.workspaceId, taskRunId: runId, }); taskRunStream.setThinking(sessionId, false); @@ -719,7 +665,7 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) { await chatWindow.refresh().catch(() => undefined); return; } - if (shouldSubscribeExistingRun(remote.status)) { + if (shouldSubscribeExistingRun(view.status)) { // 原 run 仍在跑:重订阅,不 POST 新 run settled = false; retainedRunId = runId; @@ -727,8 +673,12 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) { await subscribeTaskRunEvents(runId, { onEvent }, controller.signal); return; } - if (remote.status === 'failed' || remote.status === 'cancelled') { - failOrRetry(buildNetworkErrorInfo()); + if (view.status === 'failed') { + failOrRetry(buildFailedTaskErrorInfo(view.errorMessage)); + return; + } + if (view.status === 'cancelled') { + failOrRetry(buildCancelledTaskErrorInfo()); return; } } catch { diff --git a/web/src/lib/taskRunErrorInfo.ts b/web/src/lib/taskRunErrorInfo.ts new file mode 100644 index 00000000..a517cfd9 --- /dev/null +++ b/web/src/lib/taskRunErrorInfo.ts @@ -0,0 +1,138 @@ +import { fallbackErrorClass } from '../api/chatRetry'; +import type { ChatErrorInfo } from '../types/workbench'; + +/** GET /api/v1/tasks/runs/{id} 的最小视图。兼容修复前的 PascalCase 响应。 */ +export type RemoteTaskRunView = { + status?: string; + Status?: string; + error_message?: string; + ErrorMessage?: string; + workspace_id?: string; + WorkspaceID?: string; +}; + +export function readRemoteTaskRun(remote: RemoteTaskRunView | null | undefined): { + status: string; + errorMessage: string; + workspaceId: string; +} { + if (!remote) { + return { status: '', errorMessage: '', workspaceId: '' }; + } + return { + status: remote.status || remote.Status || '', + errorMessage: (remote.error_message || remote.ErrorMessage || '').trim(), + workspaceId: remote.workspace_id || remote.WorkspaceID || '', + }; +} + +const fatalDenialCopy: Record = { + DOC_TASK_DISABLED: '该文档任务未启用,当前请求未执行', + WORKFLOW_NOT_ACTIVATED: '当前项目尚未激活工作流,文档任务不会启动', + WORKFLOW_INSTANCE_CLOSED: '工作流实例已关闭,无法执行该文档任务', + DOC_DUTY_NOT_MANAGED: '该文档未配置职责,无法执行', + SESSION_FORBIDDEN: '无权访问该会话', +}; + +export function buildNetworkErrorInfo( + httpStatus?: number, + opts?: { errorCode?: string; detail?: string }, +): ChatErrorInfo { + const code = opts?.errorCode || ''; + const detail = opts?.detail || ''; + if (code === 'DOC_BUSY' || code === 'document_busy') { + return { + userMessage: detail || '该文档正在被其他用户或项目助手编辑,当前请求未创建新任务', + code: 'DOC_BUSY', + class: 'fatal', + actions: [], + }; + } + if (code === 'DOC_TASK_NOT_IN_ACTIVE_STAGE') { + return { + userMessage: detail || '该文档任务不在当前活动阶段,请先完成当前阶段工作流后再试', + code, + class: 'transient', + actions: ['retry'], + }; + } + if (code === 'DOC_DUTY_FORBIDDEN') { + return { + userMessage: detail || '当前账号不是该文档任务的职责人,无法执行', + code, + class: 'fatal', + actions: ['retry'], + }; + } + if (fatalDenialCopy[code]) { + const retryable = code === 'WORKFLOW_NOT_ACTIVATED' || code === 'DOC_DUTY_NOT_MANAGED'; + return { + userMessage: detail || fatalDenialCopy[code], + code, + class: 'fatal', + actions: retryable ? ['retry'] : [], + }; + } + if (httpStatus === 401) { + return { + userMessage: '登录已失效,请重新登录后再试', + code: 'ERR_UNAUTHENTICATED', + class: 'fatal', + actions: ['retry'], + }; + } + if (httpStatus === 403) { + if (code === 'ERR_FORBIDDEN' || code === 'AUTHZ') { + return { + userMessage: detail || '无权访问该项目,请确认登录账号与项目成员身份', + code: code || 'ERR_FORBIDDEN', + class: 'fatal', + actions: ['retry'], + }; + } + return { + userMessage: detail || '无权执行该操作,请确认账号权限与工作流阶段', + code: code || 'ERR_FORBIDDEN', + class: 'fatal', + actions: ['retry'], + }; + } + if (httpStatus !== undefined && httpStatus >= 400 && httpStatus < 500) { + const shown = code || `HTTP_${httpStatus}`; + return { + userMessage: detail || `请求失败(${shown}),请重试或联系管理员`, + code: shown, + class: 'fatal', + actions: ['retry'], + }; + } + return { + userMessage: '网络连接异常,请检查网络后重试', + code: 'NETWORK_ERROR', + class: fallbackErrorClass({ network: true }), + actions: ['retry'], + }; +} + +/** 服务端已判定 failed:有 error_message 时展示真实原因,否则才回落到网络错误。 */ +export function buildFailedTaskErrorInfo(errorMessage: string): ChatErrorInfo { + const message = errorMessage.trim(); + if (!message) { + return buildNetworkErrorInfo(); + } + return { + userMessage: message, + code: 'TASK_FAILED', + class: 'fatal', + actions: ['retry', 'restart'], + }; +} + +export function buildCancelledTaskErrorInfo(): ChatErrorInfo { + return { + userMessage: '任务已取消', + code: 'TASK_CANCELLED', + class: 'cancelled', + actions: ['restart'], + }; +}