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:
93 files changed
+2130
-68
No files matched your search
@@ -30,7 +30,7 @@ import type {
|
||||
ApiProxy, ConfigurableProviderView, CredentialView, GoalRef, HistoryEntry, HostFrame,
|
||||
ModelCatalogFailure, ModelProviderGroup,
|
||||
ModelReasoning, MuxFrame, QuestionResponsePayload, SessionProjectionsBlock, SessionSearchItem,
|
||||
QueuedInboxItem, SessionSummary, SettingsNamespaceView, SubagentAddress, ToolEventView,
|
||||
QueuedInboxItem, SessionSummary, SettingsNamespaceView, SubagentAddress, TaskView, ToolEventView,
|
||||
WorkspaceId, WorkspaceView,
|
||||
} from './api/index.ts'
|
||||
import {
|
||||
@@ -40,6 +40,9 @@ import {
|
||||
} from './api/session-search.ts'
|
||||
// Type-only: resolves `ctx.get('sessionProjections')` to the projection registry.
|
||||
import type {} from '@deepseek-ai/dsh-session-projection'
|
||||
// Type-only: resolves `ctx.get('tasks')` to the background task registry.
|
||||
import type {} from '@deepseek-ai/dsh-tasks'
|
||||
import type { TaskSnapshot } from '@deepseek-ai/dsh-tasks'
|
||||
// Type-only: resolves `ctx.get('sessionProjectionCache')` (the cold listing column).
|
||||
import type {} from '@deepseek-ai/dsh-session-projection-cache'
|
||||
// GoalError narrows domain rejections to their stable codes at the wire boundary.
|
||||
@@ -261,6 +264,22 @@ function subscribeSession(queue: FrameQueue<RpcRequest<MuxFrame>>, session: Sess
|
||||
queue.push(frame({ type: 'session/subscribed', sessionId: session.id, lastSeq: session.seq - 1 }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Project registry snapshots onto the wire view, dropping the three internal
|
||||
* fields {@link TaskView} documents as absent.
|
||||
*/
|
||||
function taskViews(snapshots: readonly TaskSnapshot[]): TaskView[] {
|
||||
return snapshots.map(task => ({
|
||||
id: task.id,
|
||||
kind: task.kind,
|
||||
label: task.label,
|
||||
status: task.status,
|
||||
...task.detail === undefined ? {} : { detail: task.detail },
|
||||
startedAt: task.startedAt,
|
||||
...task.finishedAt === undefined ? {} : { finishedAt: task.finishedAt },
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the session's conversation has started: no turn has run yet (a
|
||||
* turn is one model-loop execution). Standalone plugin events — command
|
||||
@@ -2586,6 +2605,19 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
queue.push(frame({ type: 'session/queue', sessionId: session.id, items: queueItems(agent) }))
|
||||
}
|
||||
}
|
||||
// Background-task baseline. `ctx.agents.get` is the non-resuming read:
|
||||
// a session with no live Agent owns no tasks, so it correctly sees only
|
||||
// the unowned ones, and listing never revives a cold session. An empty
|
||||
// set sends nothing — absence is how the client reads "no tasks".
|
||||
const tasks = ctx.get('tasks')
|
||||
if (tasks !== undefined) {
|
||||
for (const session of ctx.sessions.list()) {
|
||||
const views = taskViews(tasks.list(ctx.agents.get(session.id)))
|
||||
if (views.length > 0) {
|
||||
queue.push(frame({ type: 'session/tasks', sessionId: session.id, tasks: views }))
|
||||
}
|
||||
}
|
||||
}
|
||||
// Per-session open-call table for result-view pairing. Bounded by the
|
||||
// per-turn call count: entries clear on turn/end; a table miss (stream
|
||||
// opened mid-turn) backscans the session's in-memory events instead.
|
||||
@@ -2614,6 +2646,24 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
ctx.on('session/disposed', (session: Session) => {
|
||||
openCalls.delete(session.id)
|
||||
}),
|
||||
...tasks === undefined ? [] : [tasks.onTasksChanged((owner) => {
|
||||
if (owner !== undefined) {
|
||||
// The exact owner instance the fence compares against, so the
|
||||
// push stays correct even while that Agent's scope is tearing
|
||||
// down and a lookup by id would already miss.
|
||||
queue.push(frame({ type: 'session/tasks', sessionId: owner.id, tasks: taskViews(tasks.list(owner)) }))
|
||||
return
|
||||
}
|
||||
// An unowned task is visible to every caller, so every subscribed
|
||||
// session's set changed with it.
|
||||
for (const session of ctx.sessions.list()) {
|
||||
queue.push(frame({
|
||||
type: 'session/tasks',
|
||||
sessionId: session.id,
|
||||
tasks: taskViews(tasks.list(ctx.agents.get(session.id))),
|
||||
}))
|
||||
}
|
||||
})],
|
||||
]
|
||||
return queue.iterate(signal, () => {
|
||||
muxQueues.delete(queue)
|
||||
|
||||
Reference in New Issue
Block a user