mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-08 22:00:50 +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]
|
||||
|
||||
### 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` 作为启动默认值。
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue