mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-08 22:00: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 / 修复
|
||||
|
||||
- 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`。
|
||||
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
|
||||
var package_default = {
|
||||
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.",
|
||||
keywords: [
|
||||
"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",
|
||||
"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.",
|
||||
"keywords": [
|
||||
"deepseek-harness",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { spawn } from 'node:child_process';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { existsSync, realpathSync } from 'node:fs';
|
||||
import { isAbsolute, resolve } from 'node:path';
|
||||
|
||||
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:\…`
|
||||
* while the bot UI persists `D:\…`. Match like Win32, not POSIX string equality.
|
||||
* Canonicalize for Host workspace identity: prefer realpath when the directory
|
||||
* 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) {
|
||||
if (typeof left !== 'string' || typeof right !== 'string') return false;
|
||||
const wanted = left.trim();
|
||||
const got = right.trim();
|
||||
if (!wanted || !got) return false;
|
||||
const a = resolve(wanted);
|
||||
const b = resolve(got);
|
||||
const a = canonicalWorkspacePath(left);
|
||||
const b = canonicalWorkspacePath(right);
|
||||
if (!a || !b) return false;
|
||||
return process.platform === 'win32'
|
||||
? a.toLowerCase() === b.toLowerCase()
|
||||
: 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) {
|
||||
if (!Array.isArray(items)) return null;
|
||||
return items.find((item) => sameWorkspacePath(item?.path, workspacePath)) ?? null;
|
||||
|
|
@ -939,14 +965,60 @@ export class HarnessClient {
|
|||
}
|
||||
|
||||
async createSession(options = {}) {
|
||||
const { agentPreset: requestedPreset, ...rpcOptions } = options;
|
||||
const { agentPreset: requestedPreset, workspace, ...rpcOptions } = options;
|
||||
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 agentPreset = requestedPreset !== undefined ? requestedPreset : this.#agentPreset;
|
||||
if (agentPreset != null) payload.agentPreset = agentPreset;
|
||||
const created = await this.rpc('session.create', payload, 30_000, rpcOptions);
|
||||
return created.sessionId;
|
||||
const annotate = (error) => {
|
||||
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 = {}) {
|
||||
|
|
|
|||
|
|
@ -165,6 +165,23 @@ function rpcMethod(error) {
|
|||
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) {
|
||||
const code = typeof error?.code === 'string' ? error.code : '';
|
||||
const method = rpcMethod(error);
|
||||
|
|
@ -194,8 +211,16 @@ function failureCode(error) {
|
|||
return 'HARNESS_PROTOCOL';
|
||||
}
|
||||
if (code === 'harness-turn-failed') return 'INTERNAL_UNKNOWN';
|
||||
// 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)) {
|
||||
// Branded Host failures (slash codes) before the generic internal catch-all.
|
||||
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') {
|
||||
return 'WORKSPACE_UNAVAILABLE';
|
||||
}
|
||||
|
|
@ -212,8 +237,6 @@ function failureCode(error) {
|
|||
if (MISSING_SESSION_CODES.has(code)) return 'SESSION_NOT_FOUND';
|
||||
if (code === 'agent-busy') return 'SESSION_BUSY';
|
||||
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-')
|
||||
|| code === 'attachment-error') return 'INPUT_INVALID';
|
||||
|
||||
|
|
@ -262,10 +285,39 @@ const PERSISTED_FAILURE_CODES = new Set([
|
|||
'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) {
|
||||
try {
|
||||
const dir = join(homedir(), '.dsh', 'integrations', 'dsh-im-ops');
|
||||
await mkdir(dir, { recursive: true });
|
||||
const details = diagnosticDetails(error);
|
||||
const workspaceHint = typeof error?.workspace === 'string'
|
||||
? redactedDetail(error.workspace, { max: 240 })
|
||||
: null;
|
||||
const payload = {
|
||||
at: new Date(failure.at).toISOString(),
|
||||
referenceId: failure.referenceId,
|
||||
|
|
@ -275,6 +327,8 @@ async function persistMessageFailureDiagnostic(failure, error) {
|
|||
errorCode: typeof error?.code === 'string' ? error.code.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,
|
||||
workspace: workspaceHint,
|
||||
...(details ? { details } : {}),
|
||||
messages: diagnosticMessages(error).map((text) => text.slice(0, 240)),
|
||||
};
|
||||
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 () => {
|
||||
const client = new HarnessClient({
|
||||
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, /工作区/);
|
||||
});
|
||||
|
||||
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', () => {
|
||||
const failure = classifyMessageFailure(new Error('secret-shaped internal detail'), options);
|
||||
assert.deepEqual(failure, {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue