Sessions: restore managed Sessions resumed before the creation listener was installed
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user