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:
@@ -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()
|
||||
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/tasks/tasks/README.md
|
||||
README.md: 2f822bad139020f0ebae0165aa4e8893853f635d
|
||||
README.zh.md: fdc619fbb46267b2ae550c85cb14fcf8a916f638
|
||||
README.md: e7431cca0956429788274b0d44a249dd1e374156
|
||||
README.zh.md: e8c1162f66471eee3c04111c8617dd84295dc6cf
|
||||
|
||||
@@ -12,6 +12,7 @@ The background task registry seam (`ctx.tasks`). The abstract `TaskService` and
|
||||
- `kill(id, caller?, reason?)` invokes producer cancellation before changing status. A cancellation throw leaves the task running; success changes it to `stopping` and marks terminal delivery reported.
|
||||
- `wait(id, timeoutMs, caller?, signal?)` returns a terminal snapshot or the live snapshot at timeout. Aborting stops only the wait; settlement wins once it has committed terminal delivery to that waiter.
|
||||
- `onTaskDone(listener)` observes each terminal record with the exact owner. Listener throws and rejections are contained; listener work is not awaited.
|
||||
- `onTasksChanged(listener)` observes visible-set changes — registration, the stopping transition, settlement, and owner-disposal removal — carrying only the owner whose set moved, or `undefined` when an unowned task changed and every caller's set moved with it. It is owner-granular because removal is a change no per-task record can express, and it is not a superset of `onTaskDone`: it carries no delivery meaning and marks nothing reported.
|
||||
- `attachSurface(name)` declares a control surface for its effect lifetime. `start()` fails before producer execution when none is attached.
|
||||
|
||||
Owned access compares the task's `SessionId` with the caller's. Ids such as `bash-1` are predictable, so this fence is the boundary. Unowned tasks are open to callers and last until service disposal.
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
- `kill(id, caller?, reason?)` 在更改状态前调用生产方取消。取消抛出异常时任务保持运行;成功则把状态改为 `stopping`,并将终止交付标记为已报告。
|
||||
- `wait(id, timeoutMs, caller?, signal?)` 返回终止快照,或在超时时返回存活快照。中止只会停止等待;一旦终止交付已向该等待方提交,终止结果优先。
|
||||
- `onTaskDone(listener)` 观察每条终止记录及其精确 owner。监听器抛出的异常和产生的拒绝都会被隔离;系统不会等待监听器工作。
|
||||
- `onTasksChanged(listener)` 观察可见集合的变化——注册、转入 stopping、结算,以及 owner 销毁时的移除——只携带集合发生变化的那个 owner,或在无主任务变化、因而每个调用方的集合都随之变化时携带 `undefined`。它按 owner 分粒度,因为移除是任何逐任务记录都无法表达的变化;它也不是 `onTaskDone` 的超集:它不含任何投递含义,也不把任何东西标为已上报。
|
||||
- `attachSurface(name)` 在其 effect 生命周期内声明控制表层。如果没有附加任何表层,`start()` 会在生产方执行前失败。
|
||||
|
||||
有 owner 的访问会比较任务的 `SessionId` 与调用方。`bash-1` 等 id 可预测,因此这道隔离是安全边界。无 owner 的任务向调用方开放,并持续到服务释放。
|
||||
|
||||
@@ -15,12 +15,17 @@
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./brand": {
|
||||
"types": "./lib/types/brand.d.ts",
|
||||
"default": "./lib/types/brand.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* dsh-tasks' owned branded id, carried across the registry, the model-facing
|
||||
* control surface, and the client wire.
|
||||
*
|
||||
* It lives in its own leaf because the package root and `./types` both reach
|
||||
* `dsh-agent` through the owner and listener signatures, which a Client program
|
||||
* cannot resolve even as a type. A browser-safe consumer imports the id here;
|
||||
* `Branded<B>` itself comes from the zero-dependency `@deepseek-ai/dsh-brand`.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tasks/brand
|
||||
*/
|
||||
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
|
||||
/**
|
||||
* Identifies a background task. The registry generates `<kind>-N`; predictable
|
||||
* ids rely on owner authorization rather than secrecy.
|
||||
*/
|
||||
export type TaskId = Branded<'TaskId'>
|
||||
|
||||
/**
|
||||
* Brand a string as a {@link TaskId}.
|
||||
* @param id - the raw task-id string (the registry generates `<kind>-N`).
|
||||
* @returns the same string, branded; no validation is performed.
|
||||
*/
|
||||
export function TaskId(id: string): TaskId {
|
||||
return id as TaskId
|
||||
}
|
||||
@@ -8,7 +8,9 @@
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { TaskDoneListener, TaskId, TaskRead, TaskSnapshot, TaskStart } from './types.ts'
|
||||
import type {
|
||||
TaskDoneListener, TaskId, TaskRead, TaskSnapshot, TaskStart, TasksChangedListener,
|
||||
} from './types.ts'
|
||||
|
||||
export { TaskId } from './types.ts'
|
||||
export type {
|
||||
@@ -21,6 +23,7 @@ export type {
|
||||
TaskSnapshot,
|
||||
TaskStart,
|
||||
TaskStatus,
|
||||
TasksChangedListener,
|
||||
} from './types.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
@@ -129,6 +132,22 @@ export abstract class TaskService extends Service {
|
||||
*/
|
||||
abstract onTaskDone(listener: TaskDoneListener): () => void
|
||||
|
||||
/**
|
||||
* Register an effect-scoped observer of visible-set changes. It fires after
|
||||
* every commit that changes what {@link list} returns for that owner —
|
||||
* registration, the stopping transition, settlement, and owner-disposal
|
||||
* removal — so an observer re-reads rather than accumulating deltas.
|
||||
*
|
||||
* This is not a superset of {@link onTaskDone}: that one delivers the terminal
|
||||
* record under first-wins semantics a control surface couples to notice
|
||||
* delivery, while this one carries no delivery meaning and marks nothing
|
||||
* reported. Listeners are contained and never awaited.
|
||||
* @param listener - receives the owner whose visible set changed, or
|
||||
* `undefined` when an unowned task changed and every caller's set did.
|
||||
* @returns disposer that unregisters the listener.
|
||||
*/
|
||||
abstract onTasksChanged(listener: TasksChangedListener): () => void
|
||||
|
||||
/**
|
||||
* Attach an effect-scoped surface that can read and stop tasks. {@link start}
|
||||
* refuses work while none is attached.
|
||||
|
||||
@@ -4,24 +4,11 @@
|
||||
* @module @deepseek-ai/dsh-tasks/types
|
||||
*/
|
||||
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { TaskId } from './brand.ts'
|
||||
|
||||
/**
|
||||
* Identifies a background task. The registry generates `<kind>-N`; predictable
|
||||
* ids rely on owner authorization rather than secrecy.
|
||||
*/
|
||||
export type TaskId = Branded<'TaskId'>
|
||||
|
||||
/**
|
||||
* Brand a string as a {@link TaskId}.
|
||||
* @param id - the raw task-id string (the registry generates `<kind>-N`).
|
||||
* @returns the same string, branded; no validation is performed.
|
||||
*/
|
||||
export function TaskId(id: string): TaskId {
|
||||
return id as TaskId
|
||||
}
|
||||
export { TaskId } from './brand.ts'
|
||||
|
||||
/**
|
||||
* Task lifecycle: `running`, optionally `stopping`, then exactly one terminal
|
||||
@@ -157,3 +144,14 @@ export type TaskDoneListener = (
|
||||
snapshot: TaskSnapshot,
|
||||
owner: Agent | undefined,
|
||||
) => void | PromiseLike<void>
|
||||
|
||||
/**
|
||||
* Observation callback for a change to what one owner's {@link TaskService.list}
|
||||
* would return. It is owner-granular rather than task-granular because the
|
||||
* change may be a removal, which no per-task record can express, and because
|
||||
* its consumers re-read the whole visible set anyway.
|
||||
*
|
||||
* An `undefined` owner means an unowned task changed, so every caller's visible
|
||||
* set changed with it.
|
||||
*/
|
||||
export type TasksChangedListener = (owner: Agent | undefined) => void
|
||||
|
||||
@@ -2,7 +2,9 @@ import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { TaskId, TaskService } from '@deepseek-ai/dsh-tasks'
|
||||
import type { TaskDoneListener, TaskRead, TaskSnapshot, TaskStart } from '@deepseek-ai/dsh-tasks'
|
||||
import type {
|
||||
TaskDoneListener, TaskRead, TaskSnapshot, TaskStart, TasksChangedListener,
|
||||
} from '@deepseek-ai/dsh-tasks'
|
||||
|
||||
/**
|
||||
* Minimal concrete registry: one canned record. The seam owns the contract
|
||||
@@ -50,6 +52,10 @@ class StubTaskService extends TaskService {
|
||||
return () => {}
|
||||
}
|
||||
|
||||
onTasksChanged(_listener: TasksChangedListener): () => void {
|
||||
return () => {}
|
||||
}
|
||||
|
||||
attachSurface(_name: string): () => void {
|
||||
return () => {}
|
||||
}
|
||||
@@ -70,6 +76,8 @@ describe('TaskService seam', () => {
|
||||
await expect(ctx.tasks.wait(id, 5)).resolves.toMatchObject({ id })
|
||||
const detachListener = ctx.tasks.onTaskDone(() => {})
|
||||
detachListener()
|
||||
const detachChanges = ctx.tasks.onTasksChanged(() => {})
|
||||
detachChanges()
|
||||
detachSurface()
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user