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 / 修复 ### 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/`)。 - 发消息时若聊天绑定的 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). 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 // package.json
var package_default = { var package_default = {
name: "dsh-im-ops", 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.", description: "Ops fork of dsh-im (all channels kept). Based on @xmanrui/dsh-im@4.9.1 - access/session policies owned here.",
keywords: [ keywords: [
"deepseek-harness", "deepseek-harness",

File diff suppressed because one or more lines are too long

View file

@ -1,6 +1,6 @@
{ {
"name": "dsh-im-ops", "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.", "description": "Ops fork of dsh-im (all channels kept). Based on @xmanrui/dsh-im@4.9.1 - access/session policies owned here.",
"keywords": [ "keywords": [
"deepseek-harness", "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.', '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 暂时无法完成请求,请稍后重试。':
'DeepSeek Harness could not complete the request. Please try again later.', '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,不要立即重复提交。': '等待模型回复超时,任务可能仍在运行。请先等待或发送 /stop,不要立即重复提交。':
'Waiting for the model reply timed out and the task may still be running. Wait or send /stop before submitting it again.', '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 地址并更新相关版本。', '机器人与 DeepSeek Harness 的接口不兼容。请管理员检查 Harness 地址并更新相关版本。',
HARNESS_SERVICE: HARNESS_SERVICE:
'DeepSeek Harness 暂时无法完成请求,请稍后重试。', 'DeepSeek Harness 暂时无法完成请求,请稍后重试。',
SESSION_CREATE:
'无法在当前工作区创建新会话。请检查机器人工作区是否为绝对路径、目录存在、且运行 Host 的账号可访问;改回可用工作区后重试。',
MODEL_REPLY_TIMEOUT: MODEL_REPLY_TIMEOUT:
'等待模型回复超时,任务可能仍在运行。请先等待或发送 /stop,不要立即重复提交。', '等待模型回复超时,任务可能仍在运行。请先等待或发送 /stop,不要立即重复提交。',
MODEL_AUTH: MODEL_AUTH:
@ -159,8 +161,13 @@ function providerFailureCode(error) {
return mapped; return mapped;
} }
function rpcMethod(error) {
return typeof error?.method === 'string' ? error.method.trim() : '';
}
function failureCode(error) { function failureCode(error) {
const code = typeof error?.code === 'string' ? error.code : ''; const code = typeof error?.code === 'string' ? error.code : '';
const method = rpcMethod(error);
const providerCode = providerFailureCode(error); const providerCode = providerFailureCode(error);
if (providerCode) return providerCode; if (providerCode) return providerCode;
@ -168,12 +175,12 @@ function failureCode(error) {
if (isTransportProviderMessage(providerErrorMessage(error))) return 'MODEL_TRANSPORT'; if (isTransportProviderMessage(providerErrorMessage(error))) return 'MODEL_TRANSPORT';
if (code === 'harness-connect-failed') { if (code === 'harness-connect-failed') {
return ['session.prompt', 'session.history'].includes(error?.method) return ['session.prompt', 'session.history'].includes(method)
? 'HARNESS_RESULT_UNCERTAIN' ? 'HARNESS_RESULT_UNCERTAIN'
: 'HARNESS_CONNECT'; : 'HARNESS_CONNECT';
} }
if (code === 'harness-timeout') { if (code === 'harness-timeout') {
return ['session.prompt', 'session.history'].includes(error?.method) return ['session.prompt', 'session.history'].includes(method)
? 'HARNESS_RESULT_UNCERTAIN' ? 'HARNESS_RESULT_UNCERTAIN'
: 'HARNESS_TIMEOUT'; : 'HARNESS_TIMEOUT';
} }
@ -187,8 +194,15 @@ function failureCode(error) {
return 'HARNESS_PROTOCOL'; return 'HARNESS_PROTOCOL';
} }
if (code === 'harness-turn-failed') return 'INTERNAL_UNKNOWN'; 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 (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'; return 'HARNESS_SERVICE';
} }
if (code === 'model-empty-response') return 'MODEL_EMPTY_REPLY'; if (code === 'model-empty-response') return 'MODEL_EMPTY_REPLY';
@ -235,7 +249,20 @@ function inferredFailureReason(error) {
?? safeFailureReason(error?.name); ?? 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 { try {
const dir = join(homedir(), '.dsh', 'integrations', 'dsh-im-ops'); const dir = join(homedir(), '.dsh', 'integrations', 'dsh-im-ops');
await mkdir(dir, { recursive: true }); await mkdir(dir, { recursive: true });
@ -251,10 +278,18 @@ async function persistInternalUnknownDiagnostic(failure, error) {
messages: diagnosticMessages(error).map((text) => text.slice(0, 240)), messages: diagnosticMessages(error).map((text) => text.slice(0, 240)),
}; };
await writeFile( await writeFile(
join(dir, 'last-internal-unknown.json'), join(dir, 'last-message-failure.json'),
`${JSON.stringify(payload, null, 2)}\n`, `${JSON.stringify(payload, null, 2)}\n`,
{ encoding: 'utf8', mode: 0o600 }, { 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 { } catch {
// Diagnostics must never mask the user-facing failure path. // Diagnostics must never mask the user-facing failure path.
} }
@ -285,9 +320,10 @@ export function classifyMessageFailure(error, {
&& userMessage.trim() && userMessage.trim()
? 'INPUT_INVALID' ? 'INPUT_INVALID'
: classifiedCode; : classifiedCode;
const methodReason = safeFailureReason(rpcMethod(error)?.replaceAll('.', '_'));
const safeReason = code === 'INTERNAL_UNKNOWN' const safeReason = code === 'INTERNAL_UNKNOWN'
? (explicitReason ?? inferredFailureReason(error) ?? code) ? (explicitReason ?? inferredFailureReason(error) ?? code)
: (explicitReason ?? code); : (explicitReason ?? methodReason ?? code);
return Object.freeze({ return Object.freeze({
code, code,
reason: safeReason, reason: safeReason,
@ -306,8 +342,8 @@ export function messageFailureText(failure) {
export function setLastMessageFailure(status, error, options) { export function setLastMessageFailure(status, error, options) {
const failure = classifyMessageFailure(error, options); const failure = classifyMessageFailure(error, options);
status.lastMessageError = failure; status.lastMessageError = failure;
if (failure.code === 'INTERNAL_UNKNOWN') { if (PERSISTED_FAILURE_CODES.has(failure.code)) {
void persistInternalUnknownDiagnostic(failure, error); void persistMessageFailureDiagnostic(failure, error);
} }
return failure; 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', () => { test('RPC internal and UNKNOWN provider codes no longer fall through to INTERNAL_UNKNOWN', () => {
assert.equal(classifyMessageFailure({ assert.equal(classifyMessageFailure({
code: 'internal', method: 'session.create', 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'); }, options).code, 'HARNESS_SERVICE');
assert.equal(classifyMessageFailure({ assert.equal(classifyMessageFailure({
code: 'harness-turn-failed', providerCode: 'UNKNOWN', 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'); 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', () => { test('message failure text contains a safe code and traceable reference', () => {
const failure = classifyMessageFailure(new Error('secret-shaped internal detail'), options); const failure = classifyMessageFailure(new Error('secret-shaped internal detail'), options);
assert.deepEqual(failure, { assert.deepEqual(failure, {