merge: fix/taskrun-error-transparency-2026-09-21
This commit is contained in:
+2
-2
@@ -5,7 +5,7 @@
|
||||
|
||||
## 焦点
|
||||
|
||||
1. **任务错误透传**:`fix/taskrun-error-transparency-2026-09-21` 修复 `RunRecord` PascalCase 与前端 `NETWORK_ERROR` 误报(见 [short-term](./short-term/2026-09-21-taskrun-error-transparency.md))。追踪面板冒烟槽位已释放,可以合入。
|
||||
1. **任务错误透传**:`fix/taskrun-error-transparency-2026-09-21` 修复 `RunRecord` PascalCase 与前端 `NETWORK_ERROR` 误报(见 [short-term](./short-term/2026-09-21-taskrun-error-transparency.md))。正在占槽合入 dev。
|
||||
2. **阶段回溯与影响闭环已合入 dev**:T1–T3 与 B1–B3 在 `dev`。空追踪 JSON `null` 已修复,登录后显示「已索引 0 份文档 · 条目 0」(见 [报告](../test-reports/2026-09-21-trace-panel-null-crash-test-report.md))。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`。
|
||||
|
||||
@@ -172,7 +172,7 @@
|
||||
|
||||
| holder | phase | since | scope | queue |
|
||||
|--------|-------|-------|-------|-------|
|
||||
| - | idle | 2026-09-21 | ProjectTracePanel 空追踪修复已合入 dev;`/health` schema_version=59;浏览器显示「已索引 0 份文档 · 条目 0」 | - |
|
||||
| Cursor | merging | 2026-09-21 | 合入 `fix/taskrun-error-transparency-2026-09-21`:RunRecord snake_case 与前端错误透传;随后 `go vet`、`go test -short`、dev rebuild、`/health`、GET run JSON | - |
|
||||
|
||||
**phase**: `idle` | `merging` | `smoke` | `e2e`
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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`
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+22
-20
@@ -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 不设键。
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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'],
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user