Sessions: restore managed Sessions resumed before the creation listener was installed

This commit is contained in:
2026-11-04 18:37:42 +08:00
parent e96f84fe00
commit 4d00454496
3 changed files with 43 additions and 1 deletions
+4
View File
@@ -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.
+20 -1
View File
@@ -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<void> {
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<Agent> {
const ctx = this.ctx
+19
View File
@@ -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.