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.
This commit is contained in:
2026-11-02 04:28:26 +08:00
parent 3bee231da6
commit d527ad8045
4 changed files with 40 additions and 7 deletions
+10 -1
View File
@@ -139,12 +139,21 @@ export class Sessions {
async ensure(id: string, cwd: string, title: string, role: 'orchestrator' | 'worker'): Promise<Agent> {
const ctx = this.ctx
let result = await ctx.sessionController.resolveAgent(id as SessionId)
if ('error' in result && result.error.code === 'session/not-found') {
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()
+25 -2
View File
@@ -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<string, { id: string; path: string; sessionIds: string[]; attachSession: ReturnType<typeof vi.fn> }>()
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<ReturnType<typeof startRuntime>>
+2 -1
View File
@@ -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: () => () => {} } }
+2 -2
View File
@@ -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()