From d527ad804561b4b85fa765f229e6918cf4729c28 Mon Sep 17 00:00:00 2001 From: Wanderingss <1624155937@qq.com> Date: Mon, 2 Nov 2026 04:28:26 +0800 Subject: [PATCH] Sessions: account a restored Session to its Workspace Ensure() only registered a Workspace when the Session was new, so Sessions restored from disk never entered the Workspace record and stayed invisible in the panel. Always create (idempotent) and attach an unaccounted Session; warn on attach failure. Tests gain a shared Workspace fixture. --- src/app/sessions.ts | 13 +++++++++++-- tests/fixtures/host.ts | 27 +++++++++++++++++++++++++-- tests/model-surface.spec.ts | 3 ++- tests/runtime.spec.ts | 4 ++-- 4 files changed, 40 insertions(+), 7 deletions(-) diff --git a/src/app/sessions.ts b/src/app/sessions.ts index 1cb8267..77ac73e 100644 --- a/src/app/sessions.ts +++ b/src/app/sessions.ts @@ -139,12 +139,21 @@ export class Sessions { async ensure(id: string, cwd: string, title: string, role: 'orchestrator' | 'worker'): Promise { const ctx = this.ctx let result = await ctx.sessionController.resolveAgent(id as SessionId) - if ('error' in result && result.error.code === 'session/not-found') { - const workspace = await ctx.workspaceRegistry.create(cwd) + const created = 'error' in result && result.error.code === 'session/not-found' + // Resolve the Workspace even for a Session that already exists: a restored Session, and one adopted from another DSH + // home, must end up accounted to the Workspace its directory names, or the panel lists it nowhere. + const workspace = await ctx.workspaceRegistry.create(cwd) + if (created) { // A Session keeps the preset it was created with, one of the bundle's own (cordis.patch.yml). await ctx.sessionController.create({ sessionId: id as SessionId, workspaceId: workspace.id, agentPreset: role === 'worker' ? WORKER_PRESET : ROUTER_PRESET }) result = await ctx.sessionController.resolveAgent(id as SessionId) + } else if (!workspace.sessionIds.includes(id as SessionId)) { + // A failed attach leaves the Session usable but unlistable in its Workspace, which the panel shows and an operator can + // fix there, so it warns instead of failing the launch. + await workspace.attachSession(id as SessionId).catch((error: unknown) => { + ctx.logger.warn('dsh-superagent could not attach Session %s to Workspace %s: %s', id, workspace.id, String(error)) + }) } if ('error' in result) throw result.error const settings = this.host.store.settings() diff --git a/tests/fixtures/host.ts b/tests/fixtures/host.ts index 70ace2e..9f46280 100644 --- a/tests/fixtures/host.ts +++ b/tests/fixtures/host.ts @@ -85,6 +85,28 @@ export function jobRegistry(owner: (sessionId: string) => FakeAgent | undefined run: (owner: string, label?: string) => { const id = `bash-${jobs.size + 1}`; jobs.set(id, { id, owner, status: 'running', label }); return id } } } +/** + * DSH's Workspace registry as the Sessions use it: one Workspace per directory, which `create` resolves idempotently by path + * and which accounts the Sessions a launch attaches. `attach` records an attachment per Session so a test can assert that a + * Session restored without its Workspace registration is put back into one. + */ +export function workspaceRegistry() { + const byPath = new Map }>() + const create = vi.fn(async (cwd: string) => { + const existing = byPath.get(cwd) + if (existing) return existing + const workspace = { + id: `workspace-${byPath.size + 1}`, + path: cwd, + sessionIds: [] as string[], + attachSession: vi.fn(async (sessionId: string) => { if (!workspace.sessionIds.includes(sessionId)) workspace.sessionIds.push(sessionId) }), + } + byPath.set(cwd, workspace) + return workspace + }) + return { service: { create }, create, byPath } +} + /** * DSH's subagents as the Executor uses them. `spawn` starts a child of a Session, an agent while it lives, which a drain of * the Session's descendants refuses from then on; `settle` ends one, whose end wakes its idle parent or steers a running one @@ -238,8 +260,9 @@ export async function startRuntime(seed?: (store: Store) => void) { const webServer = { host: '127.0.0.1', port: 3082, register: (route: { handler: HttpHandler }) => { http = route.handler; return () => {} } } const jobs = jobRegistry(id => agents.get(id)) const subagents = subagentRuntime(agents) + const workspaces = workspaceRegistry() const services = { agents, llm: { resolveCallConfig: vi.fn(async (value: unknown) => value) }, sessionController: controller, - sessionPersistence: persistence, workspaceRegistry: { create: async (cwd: string) => ({ id: cwd }) }, permissionPresets: { set: () => {} }, subprocess, webServer, + sessionPersistence: persistence, workspaceRegistry: workspaces.service, permissionPresets: { set: () => {} }, subprocess, webServer, jobs: jobs.service, subagents: subagents.service } const ctx = new Context() for (const [name, service] of Object.entries(services)) ctx.reflect.provide(name, service) @@ -294,6 +317,6 @@ export async function startRuntime(seed?: (store: Store) => void) { /** A step of the worker claims what waits for it, and is cut off before the Session took it. */ const claim = (sessionId: string) => { claims.get(sessionId)!() } return { runtime, ctx, directory, agents, tools, deliveries, relays, candidates, runTool, route, acceptedTicket, api, controller, subprocess, hooks, resolveDefault, stop, - jobs, subagents, step, claim } + jobs, subagents, workspaces, step, claim } } export type Host = Awaited> diff --git a/tests/model-surface.spec.ts b/tests/model-surface.spec.ts index 13f9d41..c906777 100644 --- a/tests/model-surface.spec.ts +++ b/tests/model-surface.spec.ts @@ -13,6 +13,7 @@ import { promptParts } from '../src/app/prompts.ts' import { RESOURCES } from '../src/app/resources.ts' import { TICKET_STATES } from '../src/app/cards.ts' import { Runtime } from '../src/app/runtime.ts' +import { workspaceRegistry } from './fixtures/host.ts' import { workerSessionId } from '../src/executor/dsh.ts' import { Store } from '../src/app/store.ts' import { Commands } from '../src/infra/commands.ts' @@ -131,7 +132,7 @@ async function start() { const ctx = new Context() const history = { read: async () => ({ events: [] }), [Symbol.asyncDispose]: async () => {} } const services = { agents, llm: { resolveCallConfig: async (value: unknown) => value }, - workspaceRegistry: { create: async (cwd: string) => ({ id: cwd }) }, + workspaceRegistry: workspaceRegistry().service, sessionController: { create, resolveAgent, selectModel: async () => {}, rename: async () => {}, inspect: async () => ({ events: [] }) }, sessionPersistence: { flush: async () => {}, open: async () => history }, permissionPresets: { set: () => {} }, subprocess, webServer: { host: '127.0.0.1', port: 3082, register: () => () => {} } } diff --git a/tests/runtime.spec.ts b/tests/runtime.spec.ts index a1c9e65..ad8ffc8 100644 --- a/tests/runtime.spec.ts +++ b/tests/runtime.spec.ts @@ -9,7 +9,7 @@ import type { UserMessage } from '@deepseek-ai/dsh-llm' import { SHELL_TOOL } from '../src/app/sessions.ts' import { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import { Runtime } from '../src/app/runtime.ts' -import { jobRegistry } from './fixtures/host.ts' +import { jobRegistry, workspaceRegistry } from './fixtures/host.ts' import { evalsHealth } from '../src/app/views/overview.ts' import { mandateInstructions } from '../src/executor/dsh.ts' import { masterPrompt } from '../src/app/context.ts' @@ -204,7 +204,7 @@ beforeEach(async () => { inspect: vi.fn(async () => ({ events: [] })), } jobs = jobRegistry() - services = { agents, llm: { resolveCallConfig: vi.fn(async value => value) }, sessionController: controller, sessionPersistence: { flush: vi.fn(async () => {}), open: vi.fn(async () => ({ read: async () => ({ events: [] }), [Symbol.asyncDispose]: async () => {} })) }, workspaceRegistry: { create: async (cwd: string) => ({ id: cwd }) }, permissionPresets: { set: () => {} }, subprocess, webServer: { host: '127.0.0.1', port: 3082, register: (route: { handler: HttpHandler }) => { http = route.handler; return () => {} } }, jobs: jobs.service } + services = { agents, llm: { resolveCallConfig: vi.fn(async value => value) }, sessionController: controller, sessionPersistence: { flush: vi.fn(async () => {}), open: vi.fn(async () => ({ read: async () => ({ events: [] }), [Symbol.asyncDispose]: async () => {} })) }, workspaceRegistry: workspaceRegistry().service, permissionPresets: { set: () => {} }, subprocess, webServer: { host: '127.0.0.1', port: 3082, register: (route: { handler: HttpHandler }) => { http = route.handler; return () => {} } }, jobs: jobs.service } for (const [name, service] of Object.entries(services)) ctx.reflect.provide(name, service) for (const cli of [AGENT(), OTHER(), BOT()]) writeFileSync(cli, '#!/bin/sh\n', { mode: 0o755 }) const store = new Store(join(directory, 'orchestrator.sqlite')); seed(store); store.close()