mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-17 18:30:50 +08:00
Map Host branded create failures and retry without broken presets.
Classify gateway/internal, workspace/*, and agent-preset/* into actionable IM codes, enrich field diagnostics, realpath-match workspaces, and retry session.create once without a failing Agent Preset. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
f1c9abfd1d
commit
f1d85d2985
8 changed files with 415 additions and 220 deletions
|
|
@ -8,6 +8,12 @@ This file records the notable changes in each dsh-im release. Its format follows
|
||||||
|
|
||||||
### Fixed / 修复
|
### Fixed / 修复
|
||||||
|
|
||||||
|
- Host 带斜杠的错误码(如 `gateway/internal`、`workspace/invalid-path`、`agent-preset/not-found`)现在会正确映射为 `SESSION_CREATE` / `WORKSPACE_UNAVAILABLE` / `PRESET_UNAVAILABLE`;`last-message-failure.json` 额外写入 workspace、Host details 与脱敏 message,便于现场对上真实 RPC。
|
||||||
|
Host slash-branded failure codes (e.g. `gateway/internal`, `workspace/invalid-path`, `agent-preset/not-found`) now map to `SESSION_CREATE` / `WORKSPACE_UNAVAILABLE` / `PRESET_UNAVAILABLE`; `last-message-failure.json` also records workspace, Host details, and redacted messages for field triage.
|
||||||
|
|
||||||
|
- `session.create` 在 Agent Preset 失败或 Host `internal` 时会自动去掉该 preset 重试一次,避免坏的成员/机器人预设覆盖把首次建会话永久卡死;工作区路径匹配进一步用 `realpath`(存在时)对齐 Host 规范路径。
|
||||||
|
`session.create` now retries once without the Agent Preset after preset failures or Host `internal`, so a bad member/bot preset override cannot permanently block first Session creation; workspace path matching also prefers `realpath` when the directory exists, matching Host canonicalization.
|
||||||
|
|
||||||
- Windows 下机器人工作区路径与 Host `workspace.list` 仅盘符/路径大小写不同(如 `D:\…` vs `d:\…`)时,不再因严格字符串匹配漏掉已有工作区而去 `workspace.create` / `session.create`,避免误报 `SESSION_CREATE`。
|
- Windows 下机器人工作区路径与 Host `workspace.list` 仅盘符/路径大小写不同(如 `D:\…` vs `d:\…`)时,不再因严格字符串匹配漏掉已有工作区而去 `workspace.create` / `session.create`,避免误报 `SESSION_CREATE`。
|
||||||
On Windows, when the bot workspace path differs from a Host `workspace.list` entry only by drive/path casing (e.g. `D:\…` vs `d:\…`), IM now reuses that workspace instead of missing it via exact string match and falling through to `workspace.create` / `session.create` (which surfaced as `SESSION_CREATE`).
|
On Windows, when the bot workspace path differs from a Host `workspace.list` entry only by drive/path casing (e.g. `D:\…` vs `d:\…`), IM now reuses that workspace instead of missing it via exact string match and falling through to `workspace.create` / `session.create` (which surfaced as `SESSION_CREATE`).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.34",
|
version: "4.9.1-ops.35",
|
||||||
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",
|
||||||
|
|
|
||||||
404
lib/index.js
404
lib/index.js
File diff suppressed because one or more lines are too long
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "dsh-im-ops",
|
"name": "dsh-im-ops",
|
||||||
"version": "4.9.1-ops.34",
|
"version": "4.9.1-ops.35",
|
||||||
"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",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { existsSync, realpathSync } from 'node:fs';
|
||||||
import { isAbsolute, resolve } from 'node:path';
|
import { isAbsolute, resolve } from 'node:path';
|
||||||
|
|
||||||
import { adoptRegisteredWorkspaceSession } from './harness-session-binding.mjs';
|
import { adoptRegisteredWorkspaceSession } from './harness-session-binding.mjs';
|
||||||
|
|
@ -214,21 +215,46 @@ function workspacePaths(value) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Windows treats drive/path casing as equivalent; Host may store `d:\…`
|
* Canonicalize for Host workspace identity: prefer realpath when the directory
|
||||||
* while the bot UI persists `D:\…`. Match like Win32, not POSIX string equality.
|
* exists (matches Host `realpathNormalize`), else resolved spelling. Win32 also
|
||||||
|
* folds drive/path casing so `D:\…` and `d:\…` collide the same way Host does.
|
||||||
*/
|
*/
|
||||||
|
function canonicalWorkspacePath(value) {
|
||||||
|
if (typeof value !== 'string') return null;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
const resolved = resolve(trimmed);
|
||||||
|
try {
|
||||||
|
if (existsSync(resolved)) return realpathSync(resolved);
|
||||||
|
} catch {
|
||||||
|
// Fall through to the resolved spelling when realpath is unavailable.
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
function sameWorkspacePath(left, right) {
|
function sameWorkspacePath(left, right) {
|
||||||
if (typeof left !== 'string' || typeof right !== 'string') return false;
|
const a = canonicalWorkspacePath(left);
|
||||||
const wanted = left.trim();
|
const b = canonicalWorkspacePath(right);
|
||||||
const got = right.trim();
|
if (!a || !b) return false;
|
||||||
if (!wanted || !got) return false;
|
|
||||||
const a = resolve(wanted);
|
|
||||||
const b = resolve(got);
|
|
||||||
return process.platform === 'win32'
|
return process.platform === 'win32'
|
||||||
? a.toLowerCase() === b.toLowerCase()
|
? a.toLowerCase() === b.toLowerCase()
|
||||||
: a === b;
|
: a === b;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isPresetCreateFailure(error) {
|
||||||
|
const code = typeof error?.code === 'string' ? error.code : '';
|
||||||
|
return code.startsWith('agent-preset/') || code.startsWith('agent-preset-');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInternalCreateFailure(error) {
|
||||||
|
const code = typeof error?.code === 'string' ? error.code : '';
|
||||||
|
return code === 'internal'
|
||||||
|
|| code === 'gateway/internal'
|
||||||
|
|| code.endsWith('/internal')
|
||||||
|
|| code === 'harness-http-failed'
|
||||||
|
|| code === 'harness-rpc-rejected';
|
||||||
|
}
|
||||||
|
|
||||||
function findListedWorkspace(items, workspacePath) {
|
function findListedWorkspace(items, workspacePath) {
|
||||||
if (!Array.isArray(items)) return null;
|
if (!Array.isArray(items)) return null;
|
||||||
return items.find((item) => sameWorkspacePath(item?.path, workspacePath)) ?? null;
|
return items.find((item) => sameWorkspacePath(item?.path, workspacePath)) ?? null;
|
||||||
|
|
@ -939,14 +965,60 @@ export class HarnessClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
async createSession(options = {}) {
|
async createSession(options = {}) {
|
||||||
const { agentPreset: requestedPreset, ...rpcOptions } = options;
|
const { agentPreset: requestedPreset, workspace, ...rpcOptions } = options;
|
||||||
await this.ensureRunning(rpcOptions);
|
await this.ensureRunning(rpcOptions);
|
||||||
const workspaceId = await this.workspaceId(rpcOptions);
|
const workspacePath = typeof workspace === 'string' && workspace.trim()
|
||||||
|
? workspace.trim()
|
||||||
|
: this.#workspace;
|
||||||
|
const workspaceId = await this.workspaceId({
|
||||||
|
...rpcOptions,
|
||||||
|
...(workspacePath ? { workspace: workspacePath } : {}),
|
||||||
|
});
|
||||||
const payload = { workspaceId };
|
const payload = { workspaceId };
|
||||||
const agentPreset = requestedPreset !== undefined ? requestedPreset : this.#agentPreset;
|
const agentPreset = requestedPreset !== undefined ? requestedPreset : this.#agentPreset;
|
||||||
if (agentPreset != null) payload.agentPreset = agentPreset;
|
if (agentPreset != null) payload.agentPreset = agentPreset;
|
||||||
const created = await this.rpc('session.create', payload, 30_000, rpcOptions);
|
const annotate = (error) => {
|
||||||
return created.sessionId;
|
if (error && typeof error === 'object') {
|
||||||
|
if (workspacePath) error.workspace = workspacePath;
|
||||||
|
if (error.details == null || typeof error.details !== 'object') {
|
||||||
|
error.details = { workspaceId };
|
||||||
|
} else if (!Object.hasOwn(error.details, 'workspaceId')) {
|
||||||
|
error.details = { ...error.details, workspaceId };
|
||||||
|
}
|
||||||
|
if (payload.agentPreset && !Object.hasOwn(error.details, 'agentPreset')) {
|
||||||
|
error.details = { ...error.details, agentPreset: payload.agentPreset };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return error;
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const created = await this.rpc('session.create', payload, 30_000, rpcOptions);
|
||||||
|
return created.sessionId;
|
||||||
|
} catch (error) {
|
||||||
|
// Broken per-chat/bot presets often surface as Host internal on create.
|
||||||
|
// Retry once without the preset so a bad override does not block the chat.
|
||||||
|
if (
|
||||||
|
payload.agentPreset
|
||||||
|
&& (isPresetCreateFailure(error) || isInternalCreateFailure(error))
|
||||||
|
) {
|
||||||
|
console.warn(
|
||||||
|
`[${this.#logPrefix}] session.create failed with agentPreset `
|
||||||
|
+ `${payload.agentPreset} (${error?.code ?? 'unknown'}); retrying without preset`,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const created = await this.rpc(
|
||||||
|
'session.create',
|
||||||
|
{ workspaceId },
|
||||||
|
30_000,
|
||||||
|
rpcOptions,
|
||||||
|
);
|
||||||
|
return created.sessionId;
|
||||||
|
} catch (retryError) {
|
||||||
|
throw annotate(retryError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw annotate(error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async renameSession(sessionId, title, options = {}) {
|
async renameSession(sessionId, title, options = {}) {
|
||||||
|
|
|
||||||
|
|
@ -165,6 +165,23 @@ function rpcMethod(error) {
|
||||||
return typeof error?.method === 'string' ? error.method.trim() : '';
|
return typeof error?.method === 'string' ? error.method.trim() : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Host branded codes use `family/name`; older builds may emit bare `internal`. */
|
||||||
|
function isHostInternalCode(code) {
|
||||||
|
return code === 'internal'
|
||||||
|
|| code === 'gateway/internal'
|
||||||
|
|| code.endsWith('/internal');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHostWorkspaceCode(code) {
|
||||||
|
return code.startsWith('workspace/')
|
||||||
|
|| code.startsWith('workspace-');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHostPresetCode(code) {
|
||||||
|
return code.startsWith('agent-preset/')
|
||||||
|
|| code.startsWith('agent-preset-');
|
||||||
|
}
|
||||||
|
|
||||||
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 method = rpcMethod(error);
|
||||||
|
|
@ -194,8 +211,16 @@ 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 — refine by method so field ops see workspace vs create vs generic.
|
// Branded Host failures (slash codes) before the generic internal catch-all.
|
||||||
if (code === 'internal' || ['harness-http-failed', 'harness-rpc-rejected'].includes(code)) {
|
if (isHostPresetCode(code)) return 'PRESET_UNAVAILABLE';
|
||||||
|
if (isHostWorkspaceCode(code)) return 'WORKSPACE_UNAVAILABLE';
|
||||||
|
if (code === 'session/workspace-attach-failed' || code === 'session/conflict') {
|
||||||
|
return 'SESSION_CREATE';
|
||||||
|
} // Host RPC catch-all — refine by method so field ops see workspace vs create vs generic.
|
||||||
|
if (
|
||||||
|
isHostInternalCode(code)
|
||||||
|
|| ['harness-http-failed', 'harness-rpc-rejected'].includes(code)
|
||||||
|
) {
|
||||||
if (method === 'workspace.create' || method === 'workspace.list') {
|
if (method === 'workspace.create' || method === 'workspace.list') {
|
||||||
return 'WORKSPACE_UNAVAILABLE';
|
return 'WORKSPACE_UNAVAILABLE';
|
||||||
}
|
}
|
||||||
|
|
@ -212,8 +237,6 @@ function failureCode(error) {
|
||||||
if (MISSING_SESSION_CODES.has(code)) return 'SESSION_NOT_FOUND';
|
if (MISSING_SESSION_CODES.has(code)) return 'SESSION_NOT_FOUND';
|
||||||
if (code === 'agent-busy') return 'SESSION_BUSY';
|
if (code === 'agent-busy') return 'SESSION_BUSY';
|
||||||
if (code === 'workspace-session-stale') return 'SESSION_STALE';
|
if (code === 'workspace-session-stale') return 'SESSION_STALE';
|
||||||
if (code.startsWith('workspace-')) return 'WORKSPACE_UNAVAILABLE';
|
|
||||||
if (code.startsWith('agent-preset-')) return 'PRESET_UNAVAILABLE';
|
|
||||||
if (code.startsWith('image-') || code.startsWith('inbound-file-')
|
if (code.startsWith('image-') || code.startsWith('inbound-file-')
|
||||||
|| code === 'attachment-error') return 'INPUT_INVALID';
|
|| code === 'attachment-error') return 'INPUT_INVALID';
|
||||||
|
|
||||||
|
|
@ -262,10 +285,39 @@ const PERSISTED_FAILURE_CODES = new Set([
|
||||||
'MODEL_TRANSPORT',
|
'MODEL_TRANSPORT',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
function redactedDetail(value, { max = 160 } = {}) {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed ? trimmed.slice(0, max) : null;
|
||||||
|
}
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||||
|
if (typeof value === 'boolean') return value;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function diagnosticDetails(error) {
|
||||||
|
const details = error?.details;
|
||||||
|
if (!details || typeof details !== 'object' || Array.isArray(details)) return null;
|
||||||
|
const out = {};
|
||||||
|
for (const key of [
|
||||||
|
'path', 'workspaceId', 'sessionId', 'agentPreset', 'requestedPreset',
|
||||||
|
'existingPreset', 'requestedCwd', 'existingCwd', 'reason', 'name',
|
||||||
|
]) {
|
||||||
|
if (!Object.hasOwn(details, key)) continue;
|
||||||
|
const value = redactedDetail(details[key]);
|
||||||
|
if (value != null) out[key] = value;
|
||||||
|
}
|
||||||
|
return Object.keys(out).length > 0 ? out : null;
|
||||||
|
}
|
||||||
|
|
||||||
async function persistMessageFailureDiagnostic(failure, error) {
|
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 });
|
||||||
|
const details = diagnosticDetails(error);
|
||||||
|
const workspaceHint = typeof error?.workspace === 'string'
|
||||||
|
? redactedDetail(error.workspace, { max: 240 })
|
||||||
|
: null;
|
||||||
const payload = {
|
const payload = {
|
||||||
at: new Date(failure.at).toISOString(),
|
at: new Date(failure.at).toISOString(),
|
||||||
referenceId: failure.referenceId,
|
referenceId: failure.referenceId,
|
||||||
|
|
@ -275,6 +327,8 @@ async function persistMessageFailureDiagnostic(failure, error) {
|
||||||
errorCode: typeof error?.code === 'string' ? error.code.slice(0, 80) : null,
|
errorCode: typeof error?.code === 'string' ? error.code.slice(0, 80) : null,
|
||||||
providerCode: typeof error?.providerCode === 'string' ? error.providerCode.slice(0, 80) : null,
|
providerCode: typeof error?.providerCode === 'string' ? error.providerCode.slice(0, 80) : null,
|
||||||
method: typeof error?.method === 'string' ? error.method.slice(0, 80) : null,
|
method: typeof error?.method === 'string' ? error.method.slice(0, 80) : null,
|
||||||
|
workspace: workspaceHint,
|
||||||
|
...(details ? { details } : {}),
|
||||||
messages: diagnosticMessages(error).map((text) => text.slice(0, 240)),
|
messages: diagnosticMessages(error).map((text) => text.slice(0, 240)),
|
||||||
};
|
};
|
||||||
await writeFile(
|
await writeFile(
|
||||||
|
|
|
||||||
|
|
@ -648,6 +648,47 @@ test('HarnessClient reuses Host workspaces when only Windows path casing differs
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('HarnessClient retries session.create without agentPreset after preset/internal failure', async () => {
|
||||||
|
const client = new HarnessClient({
|
||||||
|
baseUrl: 'http://127.0.0.1:3080',
|
||||||
|
workspace: '/tmp/dsh-feishu-workspace',
|
||||||
|
agentPreset: 'broken-preset',
|
||||||
|
autostart: false,
|
||||||
|
dshBin: 'dsh',
|
||||||
|
});
|
||||||
|
const creates = [];
|
||||||
|
client.ensureRunning = async () => undefined;
|
||||||
|
client.rpc = async (method, payload) => {
|
||||||
|
if (method === 'workspace.list') {
|
||||||
|
return {
|
||||||
|
items: [{
|
||||||
|
workspaceId: 'workspace-one',
|
||||||
|
path: '/tmp/dsh-feishu-workspace',
|
||||||
|
sessionIds: [],
|
||||||
|
}],
|
||||||
|
archivedSessionIds: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (method === 'session.create') {
|
||||||
|
creates.push(payload);
|
||||||
|
if (payload.agentPreset) {
|
||||||
|
const error = new Error('agent-presets: preset "broken-preset" not found');
|
||||||
|
error.code = 'agent-preset/not-found';
|
||||||
|
error.method = 'session.create';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return { sessionId: 'session-recovered' };
|
||||||
|
}
|
||||||
|
throw new Error(`unexpected method ${method}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(await client.createSession(), 'session-recovered');
|
||||||
|
assert.deepEqual(creates, [
|
||||||
|
{ workspaceId: 'workspace-one', agentPreset: 'broken-preset' },
|
||||||
|
{ workspaceId: 'workspace-one' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
test('HarnessClient asks do not control file-return tool availability', async () => {
|
test('HarnessClient asks do not control file-return tool availability', async () => {
|
||||||
const client = new HarnessClient({
|
const client = new HarnessClient({
|
||||||
baseUrl: 'http://127.0.0.1:3080',
|
baseUrl: 'http://127.0.0.1:3080',
|
||||||
|
|
|
||||||
|
|
@ -117,6 +117,28 @@ test('session.create failures tell operators to check the bot workspace path', (
|
||||||
assert.match(failure.message, /工作区/);
|
assert.match(failure.message, /工作区/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Host branded gateway/internal on session.create maps to SESSION_CREATE', () => {
|
||||||
|
assert.equal(classifyMessageFailure({
|
||||||
|
code: 'gateway/internal', method: 'session.create',
|
||||||
|
message: 'failed to create session "session-x": Error: boom',
|
||||||
|
}, options).code, 'SESSION_CREATE');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Host branded workspace/invalid-path maps to WORKSPACE_UNAVAILABLE', () => {
|
||||||
|
assert.equal(classifyMessageFailure({
|
||||||
|
code: 'workspace/invalid-path', method: 'workspace.create',
|
||||||
|
message: 'cannot create a Workspace at "D:\\\\missing": ENOENT',
|
||||||
|
details: { path: 'D:\\missing' },
|
||||||
|
}, options).code, 'WORKSPACE_UNAVAILABLE');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Host branded agent-preset/not-found maps to PRESET_UNAVAILABLE', () => {
|
||||||
|
assert.equal(classifyMessageFailure({
|
||||||
|
code: 'agent-preset/not-found', method: 'session.create',
|
||||||
|
details: { agentPreset: 'gone' },
|
||||||
|
}, options).code, 'PRESET_UNAVAILABLE');
|
||||||
|
});
|
||||||
|
|
||||||
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, {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue