Classify session/workspace create failures and persist field diagnostics.

Map Host internal errors on session.create and workspace.create to actionable codes, and write last-message-failure.json so field hosts can triage without guessing from HARNESS_SERVICE.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-16 20:43:44 +08:00
parent f989b8bd63
commit 208c25ebbd
7 changed files with 276 additions and 219 deletions

View file

@ -8,6 +8,9 @@ This file records the notable changes in each dsh-im release. Its format follows
### Fixed / 修复
- `session.create` / `workspace.create` 的 Host `internal` 错误不再笼统显示 `HARNESS_SERVICE`:分别映射为 `SESSION_CREATE` / `WORKSPACE_UNAVAILABLE`,并写入 `%USERPROFILE%\\.dsh\\integrations\\dsh-im-ops\\last-message-failure.json`(含 method 与脱敏诊断),便于现场定位工作区路径问题。
Host `internal` failures on `session.create` / `workspace.create` no longer collapse to generic `HARNESS_SERVICE`: they map to `SESSION_CREATE` / `WORKSPACE_UNAVAILABLE`, and write `%USERPROFILE%\\.dsh\\integrations\\dsh-im-ops\\last-message-failure.json` (method + redacted diagnostics) for field triage of workspace path issues.
- 发消息时若聊天绑定的 Session 不在机器人当前配置工作区,或 history 探针返回非「会话不存在」错误,自动解绑并按机器人工作区 / Agent Preset / 默认模型新建 Session,避免换工作区后幽灵绑定一直报 `HARNESS_SERVICE`(含重建后的 Host/Client 产物 `lib/`)。
When a chat’s bound Session is outside the bot’s configured workspace, or the history probe fails with a non-missing Host error, IM now clears the binding and creates a new Session using the bot workspace / Agent Preset / default model, instead of sticking on a zombie binding that surfaces as `HARNESS_SERVICE` (includes rebuilt Host/Client `lib/` artifacts).

View file

@ -571,7 +571,7 @@ var React26 = __toESM(require("react"), 1);
// package.json
var package_default = {
name: "dsh-im-ops",
version: "4.9.1-ops.31",
version: "4.9.1-ops.32",
description: "Ops fork of dsh-im (all channels kept). Based on @xmanrui/dsh-im@4.9.1 - access/session policies owned here.",
keywords: [
"deepseek-harness",

File diff suppressed because one or more lines are too long

View file

@ -1,6 +1,6 @@
{
"name": "dsh-im-ops",
"version": "4.9.1-ops.32",
"version": "4.9.1-ops.33",
"description": "Ops fork of dsh-im (all channels kept). Based on @xmanrui/dsh-im@4.9.1 - access/session policies owned here.",
"keywords": [
"deepseek-harness",

View file

@ -45,6 +45,8 @@ export default {
'The bot and DeepSeek Harness use incompatible APIs. Ask an administrator to check the Harness address and update the related components.',
'DeepSeek Harness 暂时无法完成请求,请稍后重试。':
'DeepSeek Harness could not complete the request. Please try again later.',
'无法在当前工作区创建新会话。请检查机器人工作区是否为绝对路径、目录存在、且运行 Host 的账号可访问;改回可用工作区后重试。':
'Could not create a new Session in the current workspace. Check that the bot workspace is an absolute path, the directory exists, and the Host process can access it; then switch back to a working workspace and retry.',
'等待模型回复超时,任务可能仍在运行。请先等待或发送 /stop,不要立即重复提交。':
'Waiting for the model reply timed out and the task may still be running. Wait or send /stop before submitting it again.',
'模型凭据缺失或已失效。请管理员检查模型配置后重试。':

View file

@ -92,6 +92,8 @@ const FAILURE_MESSAGES = Object.freeze({
'机器人与 DeepSeek Harness 的接口不兼容。请管理员检查 Harness 地址并更新相关版本。',
HARNESS_SERVICE:
'DeepSeek Harness 暂时无法完成请求,请稍后重试。',
SESSION_CREATE:
'无法在当前工作区创建新会话。请检查机器人工作区是否为绝对路径、目录存在、且运行 Host 的账号可访问;改回可用工作区后重试。',
MODEL_REPLY_TIMEOUT:
'等待模型回复超时,任务可能仍在运行。请先等待或发送 /stop,不要立即重复提交。',
MODEL_AUTH:
@ -159,8 +161,13 @@ function providerFailureCode(error) {
return mapped;
}
function rpcMethod(error) {
return typeof error?.method === 'string' ? error.method.trim() : '';
}
function failureCode(error) {
const code = typeof error?.code === 'string' ? error.code : '';
const method = rpcMethod(error);
const providerCode = providerFailureCode(error);
if (providerCode) return providerCode;
@ -168,12 +175,12 @@ function failureCode(error) {
if (isTransportProviderMessage(providerErrorMessage(error))) return 'MODEL_TRANSPORT';
if (code === 'harness-connect-failed') {
return ['session.prompt', 'session.history'].includes(error?.method)
return ['session.prompt', 'session.history'].includes(method)
? 'HARNESS_RESULT_UNCERTAIN'
: 'HARNESS_CONNECT';
}
if (code === 'harness-timeout') {
return ['session.prompt', 'session.history'].includes(error?.method)
return ['session.prompt', 'session.history'].includes(method)
? 'HARNESS_RESULT_UNCERTAIN'
: 'HARNESS_TIMEOUT';
}
@ -187,8 +194,15 @@ function failureCode(error) {
return 'HARNESS_PROTOCOL';
}
if (code === 'harness-turn-failed') return 'INTERNAL_UNKNOWN';
// Host RPC catch-all (common when session/preset/workspace ops fail without a typed code).
// Host RPC catch-all — refine by method so field ops see workspace vs create vs generic.
if (code === 'internal' || ['harness-http-failed', 'harness-rpc-rejected'].includes(code)) {
if (method === 'workspace.create' || method === 'workspace.list') {
return 'WORKSPACE_UNAVAILABLE';
}
if (method === 'session.create') return 'SESSION_CREATE';
if (method.startsWith('agent-preset') || method.includes('preset')) {
return 'PRESET_UNAVAILABLE';
}
return 'HARNESS_SERVICE';
}
if (code === 'model-empty-response') return 'MODEL_EMPTY_REPLY';
@ -235,7 +249,20 @@ function inferredFailureReason(error) {
?? safeFailureReason(error?.name);
}
async function persistInternalUnknownDiagnostic(failure, error) {
const PERSISTED_FAILURE_CODES = new Set([
'INTERNAL_UNKNOWN',
'HARNESS_SERVICE',
'SESSION_CREATE',
'WORKSPACE_UNAVAILABLE',
'PRESET_UNAVAILABLE',
'HARNESS_CONNECT',
'HARNESS_TIMEOUT',
'HARNESS_ACCESS',
'HARNESS_PROTOCOL',
'MODEL_TRANSPORT',
]);
async function persistMessageFailureDiagnostic(failure, error) {
try {
const dir = join(homedir(), '.dsh', 'integrations', 'dsh-im-ops');
await mkdir(dir, { recursive: true });
@ -250,11 +277,19 @@ async function persistInternalUnknownDiagnostic(failure, error) {
method: typeof error?.method === 'string' ? error.method.slice(0, 80) : null,
messages: diagnosticMessages(error).map((text) => text.slice(0, 240)),
};
await writeFile(
join(dir, 'last-message-failure.json'),
`${JSON.stringify(payload, null, 2)}\n`,
{ encoding: 'utf8', mode: 0o600 },
);
// Keep the legacy filename for INTERNAL_UNKNOWN so older runbooks still work.
if (failure.code === 'INTERNAL_UNKNOWN') {
await writeFile(
join(dir, 'last-internal-unknown.json'),
`${JSON.stringify(payload, null, 2)}\n`,
{ encoding: 'utf8', mode: 0o600 },
);
}
} catch {
// Diagnostics must never mask the user-facing failure path.
}
@ -285,9 +320,10 @@ export function classifyMessageFailure(error, {
&& userMessage.trim()
? 'INPUT_INVALID'
: classifiedCode;
const methodReason = safeFailureReason(rpcMethod(error)?.replaceAll('.', '_'));
const safeReason = code === 'INTERNAL_UNKNOWN'
? (explicitReason ?? inferredFailureReason(error) ?? code)
: (explicitReason ?? code);
: (explicitReason ?? methodReason ?? code);
return Object.freeze({
code,
reason: safeReason,
@ -306,8 +342,8 @@ export function messageFailureText(failure) {
export function setLastMessageFailure(status, error, options) {
const failure = classifyMessageFailure(error, options);
status.lastMessageError = failure;
if (failure.code === 'INTERNAL_UNKNOWN') {
void persistInternalUnknownDiagnostic(failure, error);
if (PERSISTED_FAILURE_CODES.has(failure.code)) {
void persistMessageFailureDiagnostic(failure, error);
}
return failure;
}

View file

@ -88,6 +88,12 @@ test('PI_AI_ERROR with TLS or transport wording maps to MODEL_TRANSPORT', () =>
test('RPC internal and UNKNOWN provider codes no longer fall through to INTERNAL_UNKNOWN', () => {
assert.equal(classifyMessageFailure({
code: 'internal', method: 'session.create',
}, options).code, 'SESSION_CREATE');
assert.equal(classifyMessageFailure({
code: 'internal', method: 'workspace.create',
}, options).code, 'WORKSPACE_UNAVAILABLE');
assert.equal(classifyMessageFailure({
code: 'internal', method: 'session.prompt',
}, options).code, 'HARNESS_SERVICE');
assert.equal(classifyMessageFailure({
code: 'harness-turn-failed', providerCode: 'UNKNOWN',
@ -102,6 +108,15 @@ test('RPC internal and UNKNOWN provider codes no longer fall through to INTERNAL
assert.equal(plain.reason, 'ERROR');
});
test('session.create failures tell operators to check the bot workspace path', () => {
const failure = classifyMessageFailure({
code: 'internal', method: 'session.create',
}, options);
assert.equal(failure.code, 'SESSION_CREATE');
assert.equal(failure.reason, 'SESSION_CREATE');
assert.match(failure.message, /工作区/);
});
test('message failure text contains a safe code and traceable reference', () => {
const failure = classifyMessageFailure(new Error('secret-shaped internal detail'), options);
assert.deepEqual(failure, {