mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-18 02:00:48 +08:00
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:
parent
74c58bdcde
commit
303ca34177
5 changed files with 213 additions and 2 deletions
|
|
@ -6,6 +6,11 @@ This file records the notable changes in each dsh-im release. Its format follows
|
||||||
|
|
||||||
## [Unreleased]
|
## [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 / 变更
|
### Changed / 变更
|
||||||
|
|
||||||
- 机器人聊天文案(含 WhatsApp 访问审批)跟随 DeepSeek Harness 系统语言:打开 UI 或切换语言时通过 `host.language.set` 同步到 Host;无界面时仍可用 `language` / `DSH_IM_LANGUAGE` 作为启动默认值。
|
- 机器人聊天文案(含 WhatsApp 访问审批)跟随 DeepSeek Harness 系统语言:打开 UI 或切换语言时通过 `host.language.set` 同步到 Host;无界面时仍可用 `language` / `DSH_IM_LANGUAGE` 作为启动默认值。
|
||||||
|
|
|
||||||
|
|
@ -1285,6 +1285,16 @@ export function createBotWorkspaceScope(
|
||||||
const sessionGenerations = new Map();
|
const sessionGenerations = new Map();
|
||||||
const scopedHarness = new Proxy(harness, {
|
const scopedHarness = new Proxy(harness, {
|
||||||
get(target, property) {
|
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') {
|
if (property === 'agentPresetSettings') {
|
||||||
return async (options = {}) => {
|
return async (options = {}) => {
|
||||||
options?.signal?.throwIfAborted();
|
options?.signal?.throwIfAborted();
|
||||||
|
|
|
||||||
|
|
@ -1,6 +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 { isAbsolute } from 'node:path';
|
import { isAbsolute, resolve } from 'node:path';
|
||||||
|
|
||||||
import { adoptRegisteredWorkspaceSession } from './harness-session-binding.mjs';
|
import { adoptRegisteredWorkspaceSession } from './harness-session-binding.mjs';
|
||||||
import {
|
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 = {}) {
|
async respondInteraction(rpcId, result, options = {}) {
|
||||||
if (typeof rpcId !== 'string' || !rpcId) throw new TypeError('rpcId is required');
|
if (typeof rpcId !== 'string' || !rpcId) throw new TypeError('rpcId is required');
|
||||||
if (!result || typeof result !== 'object' || typeof result.ok !== 'boolean') {
|
if (!result || typeof result !== 'object' || typeof result.ok !== 'boolean') {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { withSessionBindingLock } from './session-binding-lock.mjs';
|
import { withSessionBindingLock } from './session-binding-lock.mjs';
|
||||||
|
import { isMissingSessionError } from './message-failure.mjs';
|
||||||
import { initialSessionTitle } from './session-title.mjs';
|
import { initialSessionTitle } from './session-title.mjs';
|
||||||
|
|
||||||
export const WORKSPACE_SESSION_STALE = 'workspace-session-stale';
|
export const WORKSPACE_SESSION_STALE = 'workspace-session-stale';
|
||||||
|
|
@ -36,6 +37,69 @@ async function createSession(harness, options) {
|
||||||
: harness.createSession(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
|
* Resolve, persist, and ask through a session that belongs to the bot's
|
||||||
* current workspace. A concurrent workspace switch invalidates the scoped
|
* current workspace. A concurrent workspace switch invalidates the scoped
|
||||||
|
|
@ -67,7 +131,16 @@ export async function askInWorkspaceSession({
|
||||||
const binding = await withSessionBindingLock(state, key, async () => {
|
const binding = await withSessionBindingLock(state, key, async () => {
|
||||||
let sessionId = state.sessionFor(key);
|
let sessionId = state.sessionFor(key);
|
||||||
let session = sessionId ? workspaceSession(harness, sessionId) : null;
|
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);
|
sessionId = await createSession(harness, createOptions);
|
||||||
if (await state.setSession(key, sessionId) === false) return null;
|
if (await state.setSession(key, sessionId) === false) return null;
|
||||||
session = workspaceSession(harness, sessionId);
|
session = workspaceSession(harness, sessionId);
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,96 @@ test('workspace asks collect result files without an explicit Gate', async () =>
|
||||||
assert.equal(typeof observed[0].onArtifact, 'function');
|
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) => {
|
test('BotWorkspaceStore persists the creation default and keeps bots isolated', async (t) => {
|
||||||
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
|
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
|
||||||
const store = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
|
const store = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue