feat(web): list background tasks in the session header

The task registry has run every background bash, pwsh, pty-send, and
one-shot subagent since it landed, but only the model could read it: a
human at the Web client could not see that a build was running, tell a
finished task from a stuck one, or find its outcome anywhere but the
`run_in_background` tool card that printed an id and never updated.

Task state now reaches the browser as one whole-snapshot `session/tasks`
mux frame per session, pushed at every registry commit that changes what
that session can see. `TaskService` gains `onTasksChanged`, which is
owner-granular because owner-disposal removal is a change no per-task
record can express. The carrier reads the exact owner the listener hands
it, so a push stays correct while that scope tears down, and reads the
baseline through the non-resuming `ctx.agents.get` so listing never
revives a cold session. The client keeps a last-wins mirror on
`SessionListState`, and a new `dsh-client-ui-task` package renders it
beside the subagent catalog — rendering nothing at all until the session
has a task, so an ordinary conversation grows no new chrome.

Streamed per-task output and human-initiated cancellation are separate
phases; the note records why neither has to undo this channel, and why
no Web path may call the consuming `ctx.tasks.read()`.
This commit is contained in:
Yichen Jiang
2026-08-08 23:29:41 +08:00
parent 22609ea425
commit eab0aeb9db
93 changed files with 2130 additions and 68 deletions

View File

@@ -13,7 +13,10 @@ import { Context } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
import { TaskService, TaskId } from '@deepseek-ai/dsh-tasks'
import type { TaskDoneListener, TaskKind, TaskOutcome, TaskRead, TaskSnapshot, TaskStart, TaskStatus } from '@deepseek-ai/dsh-tasks'
import type {
TaskDoneListener, TaskKind, TaskOutcome, TaskRead, TaskSnapshot, TaskStart, TaskStatus,
TasksChangedListener,
} from '@deepseek-ai/dsh-tasks'
/** Timeout code that distinguishes a bounded wait from caller cancellation. */
export const TASK_WAIT_TIMEOUT = 'TASK_WAIT_TIMEOUT'
@@ -59,6 +62,7 @@ export class LocalTaskService extends TaskService {
private counters = new Map<string, number>()
private surfaces = new Set<symbol>()
private listeners = new Set<TaskDoneListener>()
private changeListeners = new Set<TasksChangedListener>()
private listenersClosed = false
/** Owner agents with attached scope cleanup, mapped to the exact disposer. */
private ownerCleanups = new Map<Agent, () => Promise<void> | void>()
@@ -119,6 +123,9 @@ export class LocalTaskService extends TaskService {
this.settle(task, { status: 'failed', detail: String(error) })
},
)
// Registration is complete and cannot fail from here, so the visible set
// has genuinely changed.
this.notifyChanged(task.owner)
return id
}
@@ -156,6 +163,7 @@ export class LocalTaskService extends TaskService {
task.cancel(reason)
task.status = 'stopping'
task.reported = true
this.notifyChanged(task.owner)
return 'requested'
}
@@ -217,6 +225,14 @@ export class LocalTaskService extends TaskService {
return () => void dispose()
}
onTasksChanged(listener: TasksChangedListener): () => void {
const dispose = this.ctx.effect(() => {
this.changeListeners.add(listener)
return () => this.changeListeners.delete(listener)
}, 'tasks.onTasksChanged()')
return () => void dispose()
}
attachSurface(name: string): () => void {
// One token per call keeps duplicate labels independently disposable.
const token = Symbol(name)
@@ -262,6 +278,20 @@ export class LocalTaskService extends TaskService {
}
}
/**
* Announce that one owner's visible set changed. Each listener is contained
* so an observer cannot break a lifecycle commit that already happened.
*/
private notifyChanged(owner: Agent | undefined): void {
for (const listener of this.changeListeners) {
try {
listener(owner)
} catch (error: unknown) {
this.selfCtx.logger.warn(`tasks: onTasksChanged listener threw: ${String(error)}`)
}
}
}
/**
* Record the first terminal outcome, notify contained listeners, and release
* waiters. First-wins preserves a teardown force-failure against late producer
@@ -291,6 +321,7 @@ export class LocalTaskService extends TaskService {
task.waitResolvers.clear()
for (const resolveWait of waitResolvers) resolveWait()
task.markSettled()
this.notifyChanged(task.owner)
}
/**
@@ -323,6 +354,9 @@ export class LocalTaskService extends TaskService {
this.cancelForTeardown(owned, 'owner disposed')
await Promise.all(owned.map(task => task.settled))
for (const task of owned) this.store.delete(task.id)
// Removal is the one visible-set change no per-task record carries, so it
// must be announced here or an observer keeps the dropped rows forever.
if (owned.length > 0) this.notifyChanged(owner)
}
/**
@@ -336,6 +370,11 @@ export class LocalTaskService extends TaskService {
this.cancelForTeardown(all, 'tasks service disposed')
await Promise.all(all.map(task => task.settled))
this.store.clear()
// No change notification here: every `onTasksChanged` registration is an
// effect on this service's own fiber, so the listeners are already gone by
// the time service teardown reaches this line. An observer learns the
// registry left through its own disposal, not through a final empty set.
this.changeListeners.clear()
// Detach cross-fiber owner effects after the shared store is quiescent.
const ownerCleanups = [...this.ownerCleanups.values()]
this.ownerCleanups.clear()

View File

@@ -760,3 +760,96 @@ describe('LocalTaskService disposal', () => {
expect(() => ctx.tasks.start(producer().spec)).toThrow('no control surface is attached')
})
})
describe('LocalTaskService.onTasksChanged', () => {
it('fires after registration, the stopping transition, and settlement', async () => {
const ctx = await harness()
const owner = stubAgent(ctx, 'alice')
ctx.agents.register(owner)
const seen: (string | undefined)[] = []
ctx.tasks.onTasksChanged(changed => void seen.push(changed?.id))
const p = producer({ owner })
const id = ctx.tasks.start(p.spec)
// Registration is announced only once the record is readable.
expect(seen).toEqual(['alice'])
expect(ctx.tasks.list(owner)).toHaveLength(1)
expect(ctx.tasks.kill(id, owner)).toBe('requested')
expect(seen).toEqual(['alice', 'alice'])
expect(ctx.tasks.get(id, owner).status).toBe('stopping')
p.settle({ status: 'killed' })
await tick()
expect(seen).toEqual(['alice', 'alice', 'alice'])
expect(ctx.tasks.get(id, owner).status).toBe('killed')
await disposeAgentScope(owner)
})
it('reports an unowned change as undefined, since every caller can see it', async () => {
const ctx = await harness()
const seen: (string | undefined)[] = []
ctx.tasks.onTasksChanged(changed => void seen.push(changed?.id))
ctx.tasks.start(producer().spec)
expect(seen).toEqual([undefined])
})
it('announces the owner-disposal removal, and stays silent when that owner had none', async () => {
const ctx = await harness()
const owner = stubAgent(ctx, 'alice')
const bystander = stubAgent(ctx, 'bob')
ctx.agents.register(owner)
ctx.agents.register(bystander)
const p = producer({ owner })
ctx.tasks.start(p.spec)
const seen: (string | undefined)[] = []
ctx.tasks.onTasksChanged(changed => void seen.push(changed?.id))
p.settle({ status: 'completed' })
await tick()
expect(seen).toEqual(['alice'])
// Disposing an owner with no records changes no visible set.
await disposeAgentScope(bystander)
expect(seen).toEqual(['alice'])
await disposeAgentScope(owner)
expect(seen).toEqual(['alice', 'alice'])
expect(ctx.tasks.list(owner)).toEqual([])
})
it('contains a throwing listener so the lifecycle commit still stands', async () => {
const ctx = await harness()
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
const seen: (string | undefined)[] = []
ctx.tasks.onTasksChanged(() => { throw new Error('observer boom') })
ctx.tasks.onTasksChanged(changed => void seen.push(changed?.id))
const id = ctx.tasks.start(producer().spec)
expect(id).toBe('bash-1')
expect(seen).toEqual([undefined])
expect(warn).toHaveBeenCalledWith(expect.stringContaining('onTasksChanged listener threw'))
})
it('unregisters through its disposer and with its fiber (HMR safety)', async () => {
const ctx = await harness()
const seen: number[] = []
const detach = ctx.tasks.onTasksChanged(() => void seen.push(1))
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
inner.tasks.onTasksChanged(() => void seen.push(2))
}, { inject: ['tasks'] }))
ctx.tasks.start(producer().spec)
expect(seen).toEqual([1, 2])
detach()
detach() // second call of the same disposer is a no-op
ctx.tasks.start(producer().spec)
expect(seen).toEqual([1, 2, 2])
await fiber.dispose()
ctx.tasks.start(producer().spec)
expect(seen).toEqual([1, 2, 2])
})
})