From 4d0045449627b92d383d0b9594a2caf600742caf Mon Sep 17 00:00:00 2001 From: Wanderingss <1624155937@qq.com> Date: Wed, 4 Nov 2026 18:37:42 +0800 Subject: [PATCH] Sessions: restore managed Sessions resumed before the creation listener was installed --- src/app/runtime.ts | 4 ++++ src/app/sessions.ts | 21 ++++++++++++++++++++- tests/runtime.spec.ts | 19 +++++++++++++++++++ 3 files changed, 43 insertions(+), 1 deletion(-) diff --git a/src/app/runtime.ts b/src/app/runtime.ts index 3bb853b..d478bba 100644 --- a/src/app/runtime.ts +++ b/src/app/runtime.ts @@ -212,6 +212,10 @@ export class Runtime extends Service implements SourceHost { } this.sessions.install() this.executor.install() + // A Web UI can resume a Session while `setup.run()` above is still working, before `install()` subscribed to + // `agent/created`. Re-decorate every live managed Session so none is left with DSH's default preset and no + // Orchestrator tools; `Sessions.restore()` is idempotent and harmless for ones the listener already covered. + await this.sessions.restore() await this.sessions.ensure(this.masterId, this.machine.stateDir, 'dsh-superagent orchestrator', 'orchestrator') for (const id of this.settings.syncable()) await this.settings.sync(id).catch(() => { /* sync() records its failure in health. */ }) // If stop() was called while awaiting setup, session creation, or policy sync, do not start background loops. diff --git a/src/app/sessions.ts b/src/app/sessions.ts index 42c1dfa..9c6a3ba 100644 --- a/src/app/sessions.ts +++ b/src/app/sessions.ts @@ -7,7 +7,7 @@ import type { SessionId } from '@deepseek-ai/dsh-session' import { dirname } from 'node:path' import { z } from 'zod' import { key, liveTicket, type Ticket } from '../core/index.ts' -import { mandateInstructions, ticketOfSession } from '../executor/dsh.ts' +import { mandateInstructions, ticketOfSession, workerSessionId } from '../executor/dsh.ts' import type { HealthEntry } from '../infra/health.ts' import { AgentDelivery, messageId } from './delivery.ts' import type { Effects } from './effects.ts' @@ -124,6 +124,7 @@ export class Sessions { this.updateModelHealth() }) ctx.on('agent/created', async ({ agent }) => { await this.decorate(agent); return undefined }) + // Installing happens after setup, which takes a moment, so a Session can reach DSH before this subscription exists: see `restore`. // Opening a Session in DSH's Web UI before its first turn may select the default preset for it: select ours again. After // its first turn DSH refuses any selection, which leaves it as it is. ctx.inject(['agentPresets'], child => { @@ -139,6 +140,24 @@ export class Sessions { if (status === 'idle' && !this.host.closing()) { this.retryHealth.clear(agent.id); this.updateModelHealth() } }) } + /** + * Decorates managed Sessions that are already live when `install()` runs. DSH announces `agent/created` when the Web UI + * resumes a Session, which can happen while `Setup.run()` is still checking accounts — before this object subscribed. + * Such a Session gets no later creation event, so without this sweep it keeps DSH's default preset: no policy prompt, + * no runtime skills, and no `orchestrator_*` tools, and its worker can no longer ack or report to the Orchestrator. + * `decorate` is idempotent for an agent it already bound, and creation events after `install()` are handled by its listener. + */ + async restore(): Promise { + const ids = [this.host.masterId, ...this.host.store.tickets.list().map(ticket => workerSessionId(ticket.id))] + for (const id of ids) { + let agent: Agent | undefined + try { agent = this.ctx.agents.get(id as SessionId) } + catch (error) { this.ctx.logger.warn('dsh-superagent could not look up Session %s: %s', id, String(error)); continue } + if (!agent || this.bindings.has(agent)) continue + try { await this.decorate(agent) } + catch (error) { this.ctx.logger.warn('dsh-superagent could not restore Session %s: %s', id, String(error)) } + } + } /** Resolves or creates session `id` in workspace `cwd`, initializes its role model, decorates it with prompts/skills/tools, sets `danger-full-access` permissions, and renames it to `title`. */ async ensure(id: string, cwd: string, title: string, role: 'orchestrator' | 'worker'): Promise { const ctx = this.ctx diff --git a/tests/runtime.spec.ts b/tests/runtime.spec.ts index ad8ffc8..97ddf2b 100644 --- a/tests/runtime.spec.ts +++ b/tests/runtime.spec.ts @@ -1711,6 +1711,25 @@ it('selects its own preset again for a main Session or worker a person switched await expect(presets.select.mock.results[0]!.value).rejects.toMatchObject({ code: 'agent-preset/locked' }) }) +it('restores sessions that DSH resumed while setup was running, before its creation listener existed', async () => { + const { sessionId } = await acceptedTicket() + const worker = agents.get(sessionId)! + const sessionTools = tools.get(sessionId)! + controller.create.mockClear() + // `restart()` keeps the same live agents (DSH's registry is not torn down with the plugin), exactly the state where a + // Web UI resumed this worker during setup: no new `agent/created` will arrive. Stopping the old Runtime disposes its + // registrations; clear the fixture's observations so only a restore can put them back. + sections.clear(); restricts.clear(); guards.clear() + runtime = await restart() + await runtime.ready + expect(sessionTools.has('orchestrator_report')).toBe(true) + expect(sections.get(sessionId)!.text({}).includes('dsh-superagent prompt revision')).toBe(true) + expect(restricts.get(sessionId)).toBeDefined() + // Decoration was restored in place on the already-live agent; the Session was never recreated. + expect(agents.get(sessionId)).toBe(worker) + expect(controller.create).not.toHaveBeenCalledWith(expect.objectContaining({ sessionId })) +}) + it('gives a subagent of a worker only the worker\'s work tools, and none once the ticket ended', async () => { const { ticket, sessionId } = await acceptedTicket() // DSH creates the subagent under the standard preset, with the worker's Session as its parent.