Recreate IM sessions when a bound Session is outside the bot workspace.

Avoid HARNESS_SERVICE from zombie bindings after workspace switches by verifying membership and creating with the configured workspace, preset, and default model.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-16 20:22:26 +08:00
parent 74c58bdcde
commit 303ca34177
5 changed files with 213 additions and 2 deletions

View file

@ -6,6 +6,11 @@ This file records the notable changes in each dsh-im release. Its format follows
## [Unreleased]
### Fixed / 修复
- 发消息时若聊天绑定的 Session 不在机器人当前配置工作区,或 history 探针返回非「会话不存在」错误,自动解绑并按机器人工作区 / Agent Preset / 默认模型新建 Session,避免换工作区后幽灵绑定一直报 `HARNESS_SERVICE`。
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`.
### Changed / 变更
- 机器人聊天文案(含 WhatsApp 访问审批)跟随 DeepSeek Harness 系统语言:打开 UI 或切换语言时通过 `host.language.set` 同步到 Host;无界面时仍可用 `language` / `DSH_IM_LANGUAGE` 作为启动默认值。

View file

@ -1285,6 +1285,16 @@ export function createBotWorkspaceScope(
const sessionGenerations = new Map();
const scopedHarness = new Proxy(harness, {
get(target, property) {
if (property === 'currentWorkspace') {
return () => {
if (!isCurrentScope()) {
const error = new Error('找不到要修改的机器人。');
error.code = 'workspace-bot-not-found';
throw error;
}
return workspaces.workspaceFor(botId);
};
}
if (property === 'agentPresetSettings') {
return async (options = {}) => {
options?.signal?.throwIfAborted();

View file

@ -1,6 +1,6 @@
import { spawn } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { isAbsolute } from 'node:path';
import { isAbsolute, resolve } from 'node:path';
import { adoptRegisteredWorkspaceSession } from './harness-session-binding.mjs';
import {
@ -981,6 +981,39 @@ export class HarnessClient {
}
}
/**
* Whether a Session is registered under the given workspace path.
* Prefers session.list `cwd`, then workspace.list membership.
*/
async sessionBelongsToWorkspace(sessionId, workspacePath, options = {}) {
if (typeof sessionId !== 'string' || !sessionId) return false;
if (typeof workspacePath !== 'string' || !workspacePath.trim()) return false;
const wanted = resolve(workspacePath.trim());
const samePath = (value) => {
if (typeof value !== 'string' || !value.trim()) return false;
const got = resolve(value.trim());
return process.platform === 'win32'
? got.toLowerCase() === wanted.toLowerCase()
: got === wanted;
};
const sessionList = await this.rpc('session.list', {}, 30_000, options);
if (!sessionList || typeof sessionList !== 'object' || !Array.isArray(sessionList.items)) {
throw new Error('Harness returned an invalid response for session.list');
}
const item = sessionList.items.find((entry) => entry?.sessionId === sessionId);
if (!item) return false;
if (typeof item.cwd === 'string' && item.cwd.trim()) return samePath(item.cwd);
const workspaceList = await this.rpc('workspace.list', {}, 30_000, options);
const workspace = workspaceFromList(wanted, workspaceList)
?? (Array.isArray(workspaceList?.items)
? workspaceList.items.find((entry) => samePath(entry?.path))
: null);
if (!workspace) return false;
return Array.isArray(workspace.sessionIds) && workspace.sessionIds.includes(sessionId);
}
async respondInteraction(rpcId, result, options = {}) {
if (typeof rpcId !== 'string' || !rpcId) throw new TypeError('rpcId is required');
if (!result || typeof result !== 'object' || typeof result.ok !== 'boolean') {

View file

@ -1,4 +1,5 @@
import { withSessionBindingLock } from './session-binding-lock.mjs';
import { isMissingSessionError } from './message-failure.mjs';
import { initialSessionTitle } from './session-title.mjs';
export const WORKSPACE_SESSION_STALE = 'workspace-session-stale';
@ -36,6 +37,69 @@ async function createSession(harness, options) {
: harness.createSession(options);
}
function configuredWorkspacePath(harness, createOptions) {
if (typeof createOptions?.workspace === 'string' && createOptions.workspace.trim()) {
return createOptions.workspace.trim();
}
if (typeof harness?.currentWorkspace === 'function') {
const value = harness.currentWorkspace();
if (typeof value === 'string' && value.trim()) return value.trim();
}
return null;
}
/**
* Prefer the chat's bound Session only when it still exists under the bot's
* configured workspace. Otherwise drop the binding and create a fresh Session
* (workspace + agent preset + default model are applied by createSession).
*/
async function boundSessionReusable({
session,
harness,
createOptions,
existsOptions,
}) {
if (!session) return false;
try {
if (!(await sessionExists(session, existsOptions))) return false;
} catch (error) {
if (error?.code === WORKSPACE_SESSION_STALE || existsOptions?.signal?.aborted) throw error;
if (isMissingSessionError(error)) return false;
// History probe failed with a non-missing code (often Host `internal` on a
// zombie binding). Recreate from bot settings instead of surfacing HARNESS_SERVICE.
console.warn(
'[dsh-im] bound Session probe failed; recreating from bot workspace settings:',
error?.message ?? error,
);
return false;
}
const workspacePath = configuredWorkspacePath(harness, createOptions);
if (!workspacePath) return true;
if (typeof harness?.sessionBelongsToWorkspace !== 'function') return true;
try {
const belongs = await harness.sessionBelongsToWorkspace(
session.sessionId,
workspacePath,
existsOptions,
);
if (!belongs) {
console.warn(
`[dsh-im] bound Session ${session.sessionId} is outside workspace ${workspacePath}; recreating`,
);
}
return belongs;
} catch (error) {
if (error?.code === WORKSPACE_SESSION_STALE || existsOptions?.signal?.aborted) throw error;
console.warn(
'[dsh-im] unable to verify Session workspace membership; recreating:',
error?.message ?? error,
);
return false;
}
}
/**
* Resolve, persist, and ask through a session that belongs to the bot's
* current workspace. A concurrent workspace switch invalidates the scoped
@ -67,7 +131,16 @@ export async function askInWorkspaceSession({
const binding = await withSessionBindingLock(state, key, async () => {
let sessionId = state.sessionFor(key);
let session = sessionId ? workspaceSession(harness, sessionId) : null;
if (!session || !(await sessionExists(session, existsOptions))) {
const reusable = await boundSessionReusable({
session,
harness,
createOptions,
existsOptions,
});
if (!reusable) {
if (sessionId && typeof state.clearSession === 'function') {
await state.clearSession(key);
}
sessionId = await createSession(harness, createOptions);
if (await state.setSession(key, sessionId) === false) return null;
session = workspaceSession(harness, sessionId);

View file

@ -80,6 +80,96 @@ test('workspace asks collect result files without an explicit Gate', async () =>
assert.equal(typeof observed[0].onArtifact, 'function');
});
test('ask recreates when the bound Session is outside the configured workspace', async () => {
const calls = [];
const state = {
sessions: { chat: 'session-old-workspace' },
sessionFor(key) { return this.sessions[key] ?? null; },
async clearSession(key) { delete this.sessions[key]; },
async setSession(key, sessionId) {
this.sessions[key] = sessionId;
return true;
},
};
const harness = {
currentWorkspace: () => '/bot/workspace',
async sessionExists(sessionId) { return sessionId === 'session-old-workspace'; },
async sessionBelongsToWorkspace(sessionId, workspace) {
calls.push(['belongs', sessionId, workspace]);
return false;
},
async createSession(options) {
calls.push(['create', options]);
return 'session-new-workspace';
},
async ask(sessionId, text) {
calls.push(['ask', sessionId, text]);
return `ok:${sessionId}`;
},
};
assert.deepEqual(await askInWorkspaceSession({
harness,
state,
key: 'chat',
text: 'hello',
createOptions: { workspace: '/bot/workspace', agentPreset: 'netxops' },
}), {
sessionId: 'session-new-workspace',
answer: 'ok:session-new-workspace',
});
assert.deepEqual(calls, [
['belongs', 'session-old-workspace', '/bot/workspace'],
['create', { workspace: '/bot/workspace', agentPreset: 'netxops' }],
['ask', 'session-new-workspace', 'hello'],
]);
assert.equal(state.sessionFor('chat'), 'session-new-workspace');
});
test('ask recreates when the bound Session probe fails with a non-missing Host error', async () => {
const calls = [];
const state = {
sessions: { chat: 'session-zombie' },
sessionFor(key) { return this.sessions[key] ?? null; },
async clearSession(key) { delete this.sessions[key]; },
async setSession(key, sessionId) {
this.sessions[key] = sessionId;
return true;
},
};
const harness = {
async sessionExists(sessionId) {
calls.push(['exists', sessionId]);
const error = new Error('session.history: boom');
error.code = 'internal';
throw error;
},
async createSession() {
calls.push(['create']);
return 'session-fresh';
},
async ask(sessionId, text) {
calls.push(['ask', sessionId, text]);
return 'recovered';
},
};
assert.deepEqual(await askInWorkspaceSession({
harness,
state,
key: 'chat',
text: 'retry',
}), {
sessionId: 'session-fresh',
answer: 'recovered',
});
assert.deepEqual(calls, [
['exists', 'session-zombie'],
['create'],
['ask', 'session-fresh', 'retry'],
]);
});
test('BotWorkspaceStore persists the creation default and keeps bots isolated', async (t) => {
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
const store = await new BotWorkspaceStore(path, { defaultWorkspace }).load();