Files
deepseek-harness/apps/desktop/tests/host-update-tasks.spec.ts
T
Yichen Jiang bb20149360 refactor(shell): register foreground commands as jobs at start and drop the promotion protocol
With a job registry composed, tool-bash and tool-pwsh now register every
command with ctx.jobs as it starts and wait on the job with the registry's
own bounded wait: a command that finishes in time returns the ordinary
foreground result and its record is removed, one that outlives the timeout
returns the same job id it already had, and a kill from outside the call is
reported as `[stopped: <reason>]`. Nothing changes hands at the deadline, so
the shell seam loses `onExpiry: 'offer'`, `ShellExecution.promotion`, and
`ShellPromotionOffer`, and both local executors drop their offer arm.

The registry stays optional: the tools register a foreground-only definition
while no registry is composed and swap to the job-backed one inside a
`ctx.inject(['jobs'])` fork, so a composition without jobs keeps a plain bash
with no job tools in the model's schema. A refused registration at the start
(admission, no controller) runs under the executor's deadline kill, and a wait
that expires before the process spawned ends as the preparation timeout.

JobRegistry gains `remove(id, caller?)` and the `settled` event reports
`awaited`, so dsh-tool-jobs no longer keeps its own wait ledger; it withholds
notices for awaited settlements and for kills the model requested. The tool
catalog harvest composes the registry so the shipped schema stays documented,
and the two compaction fixtures move their context window from 10000 to 10300
tokens because the branch's larger tool schemas pushed their condensed history
over the old trigger, which requested a fourth scripted model call.
2026-09-21 15:23:08 +08:00

170 lines
6.8 KiB
TypeScript

import { Context } from '@deepseek-ai/cordis'
import { JobId, JobRegistry } from '@deepseek-ai/dsh-jobs'
import type { JobStatus, JobView } from '@deepseek-ai/dsh-jobs'
import { SessionId } from '@deepseek-ai/dsh-session/types'
import { Readable } from 'node:stream'
import type { IncomingMessage, ServerResponse } from 'node:http'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { installDesktopUpdateTaskControl } from '../../desktop-host/src/update-tasks.ts'
type AgentState = { id: SessionId; status: 'idle' | 'running'; inbox: { nextTurn: object[]; nextStep: object[] } }
let ctx: Context
let inspect: ReturnType<typeof installDesktopUpdateTaskControl>
const agents: AgentState[] = []
const jobs = new Map<SessionId | undefined, JobStatus[]>()
/** A registry whose only served member is the per-owner roster the inspector reads. */
class RosterOnlyJobRegistry extends JobRegistry {
readonly events = { subscribe: () => () => {} }
start(): never { throw new Error('unsupported') }
list(caller?: SessionId): JobView[] {
return (jobs.get(caller) ?? []).map((status, index) => ({
id: JobId(`bash-${index + 1}`),
kind: 'bash',
label: 'sleep 60',
...caller === undefined ? {} : { owner: caller },
status,
startedAt: 0,
output: { total: 0, earliest: 0 },
}))
}
get(): never { throw new Error('unsupported') }
read(): never { throw new Error('unsupported') }
readAt(): never { throw new Error('unsupported') }
kill(): never { throw new Error('unsupported') }
wait(): never { throw new Error('unsupported') }
remove(): never { throw new Error('unsupported') }
attachController(): () => void { return () => {} }
}
beforeEach(() => {
agents.length = 0
jobs.clear()
ctx = new Context()
// Narrow service doubles exercise the inspector against a real Cordis event lifecycle.
ctx.provide('agents', { list: () => agents } as unknown as Context['agents'])
new RosterOnlyJobRegistry(ctx)
inspect = installDesktopUpdateTaskControl(ctx)
})
afterEach(async () => { await ctx.fiber.dispose() })
function idleAgent(): AgentState { return { id: SessionId('update-task-owner'), status: 'idle', inbox: { nextTurn: [], nextStep: [] } } }
function request(next: () => Promise<void>) {
const incoming = Readable.from([]) as IncomingMessage
const response = { writeHead: vi.fn(), end: vi.fn() }
return { response, done: ctx.waterfall('connection/request', incoming, response as unknown as ServerResponse, next) }
}
describe('Desktop Host update task protection', () => {
it.each(['running', 'nextTurn', 'nextStep'] as const)('counts %s agent work without stopping it', async (kind) => {
const agent = idleAgent()
if (kind === 'running') agent.status = 'running'
else agent.inbox[kind].push({ queued: true })
agents.push(agent)
expect(await inspect('inspect')).toBe(true)
agent.status = 'idle'
agent.inbox.nextTurn.length = 0
agent.inbox.nextStep.length = 0
expect(await inspect('inspect')).toBe(false)
})
it.each(['running', 'stopping'] as const)('counts global and agent-owned %s jobs', async (status) => {
const agent = idleAgent()
agents.push(agent)
for (const owner of [undefined, agent.id]) {
jobs.set(owner, [status])
expect(await inspect('inspect')).toBe(true)
jobs.set(owner, ['completed'])
expect(await inspect('inspect')).toBe(false)
}
})
it('does not warn for reads but drains admitted requests before completing the lock', async () => {
const entered = Promise.withResolvers<undefined>()
const finish = Promise.withResolvers<undefined>()
const next = vi.fn(async () => { entered.resolve(undefined); await finish.promise })
const pending = request(next)
try {
await entered.promise
expect(await inspect('inspect')).toBe(false)
let drained = false
const locking = inspect('lock').then((active) => { drained = true; return active })
const refused = request(next)
await refused.done
expect(refused.response.writeHead).toHaveBeenCalledWith(503)
expect(refused.response.end).toHaveBeenCalledOnce()
expect(next).toHaveBeenCalledOnce()
expect(drained).toBe(false)
finish.resolve(undefined)
expect(await locking).toBe(false)
expect(await inspect('unlock')).toBe(false)
const admitted = vi.fn(async () => {})
await request(admitted).done
expect(admitted).toHaveBeenCalledOnce()
} finally {
finish.resolve(undefined)
await pending.done
}
expect(await inspect('inspect')).toBe(false)
})
it('detects task creation by an admitted write before handing off the lock', async () => {
const entered = Promise.withResolvers<undefined>()
const finish = Promise.withResolvers<undefined>()
const pending = request(async () => {
entered.resolve(undefined)
await finish.promise
const agent = idleAgent()
agent.inbox.nextTurn.push({ queued: true })
agents.push(agent)
})
try {
await entered.promise
expect(await inspect('inspect')).toBe(false)
const locking = inspect('lock')
finish.resolve(undefined)
expect(await locking).toBe(true)
} finally { finish.resolve(undefined); await pending.done }
})
it.each(['unlock', 'dispose'] as const)('rejects a draining lock invalidated by %s', async (action) => {
const entered = Promise.withResolvers<undefined>()
const finish = Promise.withResolvers<undefined>()
const pending = request(async () => { entered.resolve(undefined); await finish.promise })
try {
await entered.promise
const locking = inspect('lock')
const rejected = expect(locking).rejects.toThrow(action === 'unlock' ? 'lock was superseded' : 'Host is stopping')
if (action === 'unlock') await inspect('unlock')
else await ctx.fiber.dispose()
finish.resolve(undefined)
await rejected
} finally { finish.resolve(undefined); await pending.done }
})
it('releases request accounting when the bridge rejects', async () => {
await expect(request(async () => { throw new Error('disconnected') }).done).rejects.toThrow('disconnected')
expect(await inspect('inspect')).toBe(false)
})
it('removes admission on disposal and refuses further inspection', async () => {
expect(await inspect('lock')).toBe(false)
await ctx.fiber.dispose()
const next = vi.fn(async () => {})
await request(next).done
expect(next).toHaveBeenCalledOnce()
await expect(inspect('inspect')).rejects.toThrow('Host is stopping')
})
it('does not classify unavailable task services as idle', async () => {
const empty = new Context()
try {
const unavailable = installDesktopUpdateTaskControl(empty)
await expect(unavailable('lock')).rejects.toThrow('task services are unavailable')
} finally { await empty.fiber.dispose() }
})
})