diff --git a/CHANGELOG.md b/CHANGELOG.md index 13a38cd..7825a44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` 作为启动默认值。 diff --git a/src/channels/shared/bot-workspace-store.mjs b/src/channels/shared/bot-workspace-store.mjs index 4d05e8e..6026167 100644 --- a/src/channels/shared/bot-workspace-store.mjs +++ b/src/channels/shared/bot-workspace-store.mjs @@ -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(); diff --git a/src/channels/shared/harness-client.mjs b/src/channels/shared/harness-client.mjs index b0c8e58..3398ca2 100644 --- a/src/channels/shared/harness-client.mjs +++ b/src/channels/shared/harness-client.mjs @@ -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') { diff --git a/src/channels/shared/workspace-session.mjs b/src/channels/shared/workspace-session.mjs index 474b5e4..f4d9276 100644 --- a/src/channels/shared/workspace-session.mjs +++ b/src/channels/shared/workspace-session.mjs @@ -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); diff --git a/test/workspace.test.mjs b/test/workspace.test.mjs index 485f9da..7d54f30 100644 --- a/test/workspace.test.mjs +++ b/test/workspace.test.mjs @@ -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();