refactor(session-controller): drop the job roster from the control stream

The control baseline and frames carry queues and projections only; the
SessionJob row type, the jobs frame, the registry subscription, and the
client jobsBySession mirror are gone, and every client fixture drops the
empty mirror. The roster moves to dsh-api-job-controller's job.rows
stream in the next change.
This commit is contained in:
Yichen Jiang committed 2026-09-03 16:43:15 +08:00
1 parent 10d76fbd03
commit 2132b7beeb
44 files changed
+52 -466

No files matched your search

-1
View File
@@ -34,7 +34,6 @@ export type {} from '@deepseek-ai/dsh-api-job-controller/remote'
export type * from '@deepseek-ai/dsh-api-job-controller/types'
export type {} from '@deepseek-ai/dsh-api-workspace-controller/remote'
export type * from '@deepseek-ai/dsh-api-workspace-controller/types'
export type { SessionJob as JobView } from '@deepseek-ai/dsh-api-session-controller/types'
// The forwarded-event allowlist's selection seat: without it in the consumer's
// compilation face `TypertRemoteEvent` is `never` and every `$on` call fails.
export type { ApiRemoteForwardedEvent } from '../types.ts'
@@ -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/api/session-controller/README.md
README.md: 4414cd7221b40cc880122415e83b6dc3818df481
README.zh.md: 8f457d4a94f57c802f67624d9c84ba15c78b1ada
README.md: 941c255d3923b4b51635381b2f45f83780bb60c8
README.zh.md: 3dfe2a206fa7657e57e9c3b43e40fcd75e8c1bc0
+1 -2
View File
@@ -29,7 +29,7 @@ Each endpoint states its activation policy. List, search, attachment, history pa
The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, and `append` changes, and repairs reconnect or sequence gaps through a tail page. Backwards paging has two verbs: `loadOlder()` pulls one 50-message page, and `loadThrough(seq)` — the turn-jump loader — loops 200-message pages until the window covers the target seq, lowering a shared target on repeated calls, stopping on a page that makes no progress, and reporting busy through the same `loadingOlder` snapshot bit. Ordinary records cover `[event.seq, event.seq]`; packed rows cover `[event.seq, event.seq + memberCount - 1]`. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events.
A `SessionJob` row carries `record: true` exactly when the job declared an observation record; streaming that record is the job controller's `job.observe` (see [`dsh-api-job-controller`](../job-controller/README.md)), while this package keeps the roster on the control stream.
Background-job rows and observation streams belong to [`dsh-api-job-controller`](../job-controller/README.md); the control stream carries queues and projections only.
The Session object also carries local submission echoes: `session.beginSubmission` inserts one into `SessionSnapshot.pendingSubmissions` synchronously, before the caller serializes and prompts, so a conversation UI can show the message on the submit click's own frame. Session derives each echo's `transcript`, `queued`, or `steering` placement from its current running state and the requested delivery mode, then retains that placement while serialization is in flight. The prompt's `requestId` is the correlation identity: the Host echoes it as the durable user source's `rpcId`, and queue occurrences project it as `SessionQueuedItem.rpcId`. An echo retires one animation frame after its durable event or queue occurrence is observed (the delay keeps it renderable until the replacement is ready), immediately when its identified prompt fails or is abandoned, and as failed on disposal; each retirement fires the registered `onRetire` callback exactly once. Echoes are Client memory only; reload and reconnect rebuild the conversation from durable events alone.
@@ -61,7 +61,6 @@ No direct effect; model requests remain owned by the Agent and LLM packages.
<a id="known-limitations-and-deferred-work"></a>
- Control baselines represent process-local state and therefore cannot reconstruct jobs after a Host restart.
- A failed follow resumption remains visible to the caller instead of retrying indefinitely.
- File-reference completion uses the shared Agent lookup and can resume a cold Session; the `skills/list` catalog is the non-activating alternative for skill metadata.
+1 -2
View File
@@ -29,7 +29,7 @@ kind: "package-reference"
Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend` 和 `append` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页 50 条 message,而 `loadThrough(seq)`——轮次跳转加载器——按 200 条 message 一页循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。普通 record 覆盖 `[event.seq, event.seq]`,packed row 覆盖 `[event.seq, event.seq + memberCount - 1]`。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。
`SessionJob` 行恰在该 job 声明了观测 record 时携带 `record: true`;流式发送该 record 的是 job 控制器的 `job.observe`(见 [`dsh-api-job-controller`](../job-controller/README.zh.md)),本包只在控制流上保留名册。
后台 job 的名册行与观测流属于 [`dsh-api-job-controller`](../job-controller/README.zh.md);控制流只承载队列与投影。
Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与 prompt 之前,同步把一条回显写入 `SessionSnapshot.pendingSubmissions`,会话 UI 因此能在点击提交的当帧显示消息。Session 根据当前运行状态与请求的投递模式推导每条回显的 `transcript`、`queued` 或 `steering` 位置,并在序列化期间保留该位置。prompt 的 `requestId` 是关联标识:Host 把它回显为 durable user source 的 `rpcId`,queue occurrence 也把它投影为 `SessionQueuedItem.rpcId`。回显在观察到其 durable event 或 queue occurrence 后延迟一个动画帧退休,该延迟保证替代内容就绪前回显仍可渲染;带标识的 prompt 失败或被放弃时立即退休,销毁时按 failed 退休;每次退休恰好触发一次注册的 `onRetire` 回调。回显只存在于 Client 内存;刷新与重连只从 durable event 重建会话。
@@ -61,7 +61,6 @@ Session 对象还承载本地提交回显:`session.beginSubmission` 在调用
<a id="known-limitations-and-deferred-work"></a>
- Control baseline 表示进程本地状态,因此 Host 重启后无法重建 jobs。
- follow 恢复失败会对调用方可见,而不会无限重试。
- 文件引用补全使用共享 Agent lookup,因此可能恢复冷 Session;`skills/list` 目录是不激活 Agent 的 skill 元数据读取路径。
@@ -82,7 +82,6 @@
"@deepseek-ai/dsh-attachment": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-file-reference": "workspace:^",
"@deepseek-ai/dsh-jobs": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-native-command": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",
@@ -101,9 +100,6 @@
"@deepseek-ai/dsh-workspace": "workspace:^"
},
"peerDependenciesMeta": {
"@deepseek-ai/dsh-jobs": {
"optional": true
},
"@deepseek-ai/dsh-session-persistence": {
"optional": true
},
@@ -121,7 +117,6 @@
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-store": "workspace:^",
"@deepseek-ai/dsh-file-reference": "workspace:^",
"@deepseek-ai/dsh-jobs": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-native-command": "workspace:^",
"@deepseek-ai/dsh-permission-presets": "workspace:^",
@@ -10,7 +10,6 @@ import type {
SessionControlFrame,
SessionQueuedItem,
SessionSummary,
SessionJob as JobView,
} from '../../types.ts'
import { mergeOrderedBaseline } from '../ordered-baseline.ts'
import { isRemoteFailure } from '@deepseek-ai/dsh-api-gateway/client'
@@ -56,8 +55,6 @@ export interface SessionListSnapshot {
phase: SessionListPhase
error: RemoteFailure | null
subagentsByParent: Readonly<Record<SessionId, SubagentCatalogSnapshot>>
/** Background jobs per session; an absent key is an empty set. */
jobsBySession: Readonly<Record<SessionId, readonly JobView[]>>
currentAddress: SubagentAddress | undefined
}
@@ -126,12 +123,6 @@ export class SessionManager {
private readonly catalogStale = new Set<SessionId>()
private readonly openCatalogs = new Set<SessionId>()
private readonly catalogDebounce = new Map<SessionId, ReturnType<typeof setTimeout>>()
/**
* Background jobs per session, last-wins from Session Controller's control
* stream. An empty set is stored as an absent key, so absence and `[]` are
* one representation.
*/
private readonly jobsBySession = new Map<SessionId, readonly JobView[]>()
private selected: SessionId | undefined
@@ -669,12 +660,6 @@ export class SessionManager {
this.notifier.markDirty()
return
}
if (frame.type === 'jobs') {
if (frame.jobs.length === 0) this.jobsBySession.delete(frame.sessionId)
else this.jobsBySession.set(frame.sessionId, frame.jobs)
this.notifier.markDirty()
return
}
this.queues.set(frame.sessionId, frame.items)
this.sessions.get(frame.sessionId)?.handleControlFrame(frame)
}
@@ -685,11 +670,6 @@ export class SessionManager {
this.queues.set(sessionId as SessionId, items)
}
this.jobsBySession.clear()
for (const [sessionId, jobs] of Object.entries(baseline.jobs)) {
if (jobs.length > 0) this.jobsBySession.set(sessionId as SessionId, jobs)
}
for (const [sessionId, block] of Object.entries(baseline.projections)) {
const store = this.projectionStore(sessionId as SessionId)
const asOfSeq = sessionSeqCursor(block.asOfSeq)
@@ -739,7 +719,6 @@ export class SessionManager {
if (durableSubagent) this.sessions.get(sessionId)?.handleRunning(false)
else this.sessions.get(sessionId)?.handleRemoved()
this.queues.delete(sessionId)
this.jobsBySession.delete(sessionId)
if (!durableSubagent) this.projectionStores.delete(sessionId)
const inflightCatalog = this.catalogInflight.get(sessionId)
if (inflightCatalog !== undefined) {
@@ -951,7 +930,6 @@ export class SessionManager {
phase: this.listPhase,
error: this.listError,
subagentsByParent: Object.fromEntries(this.catalogs),
jobsBySession: Object.fromEntries(this.jobsBySession),
currentAddress: current === undefined ? undefined : this.addresses.get(current),
}
}
@@ -20,7 +20,6 @@ import { SessionSeq, type SessionId } from '@deepseek-ai/dsh-session/types'
import { workspaceTitleOf } from '@deepseek-ai/dsh-util-workspace-path'
import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
import { SESSION_SEARCH_RESULT_LIMIT } from '../../types.ts'
import type { SessionJob as JobView } from '../../types.ts'
import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types'
import {
createSnapshotStore, type SnapshotStore,
@@ -76,12 +75,6 @@ export interface SessionListState {
phase: SessionListPhase
/** Direct durable catalogs keyed by their selected parent address. */
subagentsByParent: Readonly<Record<SessionId, SubagentCatalogSnapshot>>
/**
* Background jobs each session can see, mirrored last-wins from Session
* Controller's control baseline and `jobs` frames. A missing key is an empty
* set, so consumers read absence rather than a sentinel.
*/
jobsBySession: Readonly<Record<SessionId, readonly JobView[]>>
/** Current session's catalog-derived address, absent on ordinary navigation. */
currentAddress: SubagentAddress | undefined
}
@@ -233,7 +226,7 @@ export class ClientSessions implements ISessions {
)
this.list = createSnapshotStore<SessionListState>({
ids: [], byId: {}, current: undefined, phase: 'pending',
subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined,
subagentsByParent: {}, currentAddress: undefined,
})
// The manager owns wire truth; the store is its projection. Manager
// notifications are already microtask-batched.
@@ -576,7 +569,7 @@ export class ClientSessions implements ISessions {
/** Project the manager's list snapshot into the store (title derivation is display-only). */
private projectList(): void {
const {
items, current, phase, subagentsByParent, jobsBySession, currentAddress,
items, current, phase, subagentsByParent, currentAddress,
} = this.manager.getListSnapshot()
const ids: SessionId[] = []
const byId: Record<SessionId, SessionSummary> = {}
@@ -642,7 +635,7 @@ export class ClientSessions implements ISessions {
...(currentAddress === undefined ? {} : { subagentAddress: currentAddress }),
})
}
this.list.set({ ids, byId, current, phase, subagentsByParent, jobsBySession, currentAddress })
this.list.set({ ids, byId, current, phase, subagentsByParent, currentAddress })
this.pruneScopes()
}
+2 -46
View File
@@ -1,9 +1,8 @@
/** Live Session queue, jobs, and projection state with reconnect baselines. */
/** Live Session queue and projection state with reconnect baselines. */
import type { Context } from '@deepseek-ai/cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { Deque } from '@deepseek-ai/dsh-deque'
import type { JobSnapshot } from '@deepseek-ai/dsh-jobs'
import type {
Session, SessionEvent, SessionEventMap, SessionId, UserMessage,
} from '@deepseek-ai/dsh-session'
@@ -11,7 +10,6 @@ import type { JsonValue } from '@deepseek-ai/dsh-util-values'
import type {
SessionControlBaseline,
SessionControlFrame,
SessionJob,
SessionProjectionBaseline,
SessionProjectionValues,
SessionQueuedItem,
@@ -21,7 +19,7 @@ import type {
export class SessionControlController {
private readonly streams = new Set<ControlQueue>()
/** @param ctx - Host context carrying live Agent, projection, and jobs services. */
/** @param ctx - Host context carrying live Agent and projection services. */
constructor(private readonly ctx: Context) {
ctx.on('session/event', (session, event) => { this.onSessionEvent(session, event) })
ctx.sessionProjections.onChanged((session, key, value, seq) => {
@@ -33,13 +31,6 @@ export class SessionControlController {
seq,
})
})
ctx.inject(['jobs'], (jobsCtx) => {
jobsCtx.jobs.onJobsChanged((owner) => { this.onJobsChanged(owner) })
})
ctx.on('session/created', (session) => {
const jobs = this.jobsFor(this.ctx.agents.get(session.id))
if (jobs.length > 0) this.broadcast({ type: 'jobs', sessionId: session.id, jobs })
})
ctx.effect(() => () => {
for (const stream of this.streams) stream.end()
this.streams.clear()
@@ -67,15 +58,12 @@ export class SessionControlController {
private baseline(): SessionControlBaseline {
const sessions = this.ctx.sessions.list()
const queues = Object.create(null) as Record<SessionId, readonly SessionQueuedItem[]>
const jobs = Object.create(null) as Record<SessionId, readonly SessionJob[]>
for (const session of sessions) {
const agent = this.ctx.agents.get(session.id)
queues[session.id] = agent?.session === session ? queueItems(agent) : []
jobs[session.id] = this.jobsFor(agent)
}
return {
queues,
jobs,
projections: this.projectionBaseline(sessions),
}
}
@@ -106,25 +94,6 @@ export class SessionControlController {
})
}
private onJobsChanged(owner: Agent | undefined): void {
if (owner !== undefined) {
this.broadcast({ type: 'jobs', sessionId: owner.id, jobs: this.jobsFor(owner) })
return
}
for (const session of this.ctx.sessions.list()) {
this.broadcast({
type: 'jobs',
sessionId: session.id,
jobs: this.jobsFor(this.ctx.agents.get(session.id)),
})
}
}
private jobsFor(agent: Agent | undefined): SessionJob[] {
const jobs = this.ctx.get('jobs')
return jobs === undefined ? [] : jobs.list(agent).map(jobView)
}
private broadcast(frame: SessionControlFrame): void {
for (const stream of this.streams) stream.push(frame)
}
@@ -202,16 +171,3 @@ function promptRpcId(message: UserMessage): Pick<SessionQueuedItem, 'rpcId'> {
const source = message.source
return source.kind === 'user' && 'rpcId' in source ? { rpcId: source.rpcId } : {}
}
function jobView(job: JobSnapshot): SessionJob {
return {
id: job.id,
kind: job.kind,
label: job.label,
status: job.status,
...(job.detail === undefined ? {} : { detail: job.detail }),
startedAt: job.startedAt,
...(job.finishedAt === undefined ? {} : { finishedAt: job.finishedAt }),
...(job.outputTotal === undefined ? {} : { record: true as const }),
}
}
@@ -9,7 +9,6 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { ChunkRow } from '@deepseek-ai/dsh-session/chunk-rows'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types'
import type { JobId } from '@deepseek-ai/dsh-jobs/brand'
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
@@ -479,28 +478,9 @@ export interface SessionQueuedItem {
}
}
/** Browser-safe background-job row. */
export interface SessionJob {
readonly id: JobId
readonly kind: string
readonly label: string
readonly status: 'running' | 'stopping' | 'completed' | 'killed' | 'failed'
readonly detail?: string
readonly startedAt: number
readonly finishedAt?: number
/**
* Present exactly when the job declared an observation record: the row is
* observable through the job controller's `job.observe` stream, whose
* `opened` anchor carries the live byte offsets. The roster refreshes on
* lifecycle commits only, so it mirrors no byte count.
*/
readonly record?: true
}
/** Complete live control baseline emitted once per control stream generation. */
export interface SessionControlBaseline {
readonly queues: Readonly<Record<SessionId, readonly SessionQueuedItem[]>>
readonly jobs: Readonly<Record<SessionId, readonly SessionJob[]>>
readonly projections: Readonly<Record<SessionId, SessionProjectionBaseline>>
}
@@ -516,7 +496,6 @@ export interface SessionProjectionUpdate {
export type SessionControlFrame =
| { readonly type: 'baseline'; readonly value: SessionControlBaseline }
| { readonly type: 'queue'; readonly sessionId: SessionId; readonly items: readonly SessionQueuedItem[] }
| { readonly type: 'jobs'; readonly sessionId: SessionId; readonly jobs: readonly SessionJob[] }
| ({ readonly type: 'projection' } & SessionProjectionUpdate)
@@ -1,248 +0,0 @@
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { JobOutcome } from '@deepseek-ai/dsh-jobs'
import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import type { Session } from '@deepseek-ai/dsh-session'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { describe, expect, it } from 'vitest'
import { SessionControlController } from '../src/control.ts'
import type { SessionControlFrame } from '../src/types.ts'
type BaselineFrame = Extract<SessionControlFrame, { type: 'baseline' }>
type JobFrame = Extract<SessionControlFrame, { type: 'jobs' }>
function producer(label = 'sleep 60') {
let settle!: (outcome: JobOutcome) => void
const reads = { count: 0 }
const spec = {
kind: 'bash' as const,
label,
run: () => ({
cancel: () => {},
done: new Promise<JobOutcome>((resolve) => { settle = resolve }),
readOutput: () => { reads.count += 1; return 'stolen output' },
}),
}
return { spec, reads, settle: (outcome: JobOutcome) => { settle(outcome) } }
}
async function harness(withJobs: boolean): Promise<{
ctx: Context
session: Session
agent: Agent
control: SessionControlController
}> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(SessionProjectionRegistry)
if (withJobs) {
await ctx.plugin(LocalJobRegistry)
ctx.jobs.attachController('session-controller-test')
}
const session = ctx.sessions.create()
const agent = {
id: session.id,
session,
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
status: 'idle',
ctx,
} as Agent
ctx.agents.register(agent)
const control = new SessionControlController(ctx)
await new Promise(resolve => setTimeout(resolve, 0))
return { ctx, session, agent, control }
}
async function baseline(control: SessionControlController): Promise<BaselineFrame> {
const abort = new AbortController()
const iterator = control.control(abort.signal)[Symbol.asyncIterator]()
const first = await iterator.next()
abort.abort()
await iterator.next()
if (first.done || first.value.type !== 'baseline') throw new Error('missing control baseline')
return first.value
}
async function collectJobs(
iterable: AsyncIterable<SessionControlFrame>,
count: number,
abort: AbortController,
): Promise<JobFrame[]> {
const jobs: JobFrame[] = []
for await (const frame of iterable) {
if (frame.type !== 'jobs') continue
jobs.push(frame)
if (jobs.length >= count) abort.abort()
}
return jobs
}
describe('Session control jobs baseline', () => {
it('represents an attached session with no jobs as an empty set', async () => {
const { session, control } = await harness(true)
const frame = await baseline(control)
expect(frame.value.jobs[session.id]).toEqual([])
})
it('carries the visible set when the stream opens', async () => {
const { ctx, session, agent, control } = await harness(true)
ctx.jobs.start({ ...producer('pnpm run build').spec, owner: agent })
const frame = await baseline(control)
const jobs = frame.value.jobs[session.id]
expect(jobs).toHaveLength(1)
const [job] = jobs ?? []
expect(job?.startedAt).toBeTypeOf('number')
expect({ ...job, startedAt: 0 }).toEqual({
id: 'bash-1',
kind: 'bash',
label: 'pnpm run build',
status: 'running',
startedAt: 0,
})
})
})
describe('Session control jobs updates', () => {
it('publishes existing unowned jobs when a Session attaches after the stream opens', async () => {
const { ctx, control } = await harness(true)
const abort = new AbortController()
const iterator = control.control(abort.signal)[Symbol.asyncIterator]()
await expect(iterator.next()).resolves.toMatchObject({ value: { type: 'baseline' } })
const task = producer('already running')
const id = ctx.jobs.start(task.spec)
await expect(iterator.next()).resolves.toMatchObject({ value: { type: 'jobs' } })
const created = ctx.sessions.create(SessionId('late-session'))
await expect(iterator.next()).resolves.toMatchObject({
value: {
type: 'jobs',
sessionId: created.id,
jobs: [expect.objectContaining({ id, label: 'already running' })],
},
})
task.settle({ status: 'completed' })
abort.abort()
await iterator.return?.()
})
it('pushes the owner whole set on registration, stopping, and settlement', async () => {
const { ctx, session, agent, control } = await harness(true)
const abort = new AbortController()
const collected = collectJobs(control.control(abort.signal), 3, abort)
const task = producer()
const id = ctx.jobs.start({ ...task.spec, owner: agent })
ctx.jobs.kill(id, agent, 'test')
task.settle({ status: 'killed', detail: 'signal: SIGTERM' })
const frames = await collected
expect(frames.map(frame => frame.sessionId)).toEqual([session.id, session.id, session.id])
expect(frames.map(frame => frame.jobs[0]?.status)).toEqual(['running', 'stopping', 'killed'])
expect(frames[2]?.jobs[0]?.detail).toBe('signal: SIGTERM')
expect(frames[2]?.jobs[0]?.finishedAt).toBeTypeOf('number')
})
it('drops internal registry fields from the browser view', async () => {
const { ctx, agent, control } = await harness(true)
const abort = new AbortController()
const collected = collectJobs(control.control(abort.signal), 1, abort)
ctx.jobs.start({ ...producer().spec, owner: agent, outputLimitBytes: 1_024 })
const [frame] = await collected
expect(Object.keys(frame?.jobs[0] ?? {}).sort()).toEqual([
'id',
'kind',
'label',
'startedAt',
'status',
])
})
it('flags record-declaring jobs instead of mirroring a byte count the roster never refreshes', async () => {
const { ctx, agent, control } = await harness(true)
const abort = new AbortController()
const collected = collectJobs(control.control(abort.signal), 1, abort)
ctx.jobs.start({
kind: 'bash',
label: 'observable run',
owner: agent,
record: true,
run: (job) => {
job.append('hi')
return { cancel: () => {}, done: new Promise<never>(() => {}) }
},
})
const [frame] = await collected
expect(frame?.jobs[0]).toMatchObject({ record: true })
expect(frame?.jobs[0]).not.toHaveProperty('outputTotal')
})
it('fans an unowned change out to every attached session', async () => {
const { ctx, control } = await harness(true)
const second = ctx.sessions.create()
const abort = new AbortController()
const collected = collectJobs(control.control(abort.signal), 2, abort)
ctx.jobs.start(producer('open to every caller').spec)
const frames = await collected
expect(new Set(frames.map(frame => frame.sessionId)).size).toBe(2)
expect(frames.some(frame => frame.sessionId === second.id)).toBe(true)
for (const frame of frames) expect(frame.jobs[0]?.label).toBe('open to every caller')
})
it('does not resume persisted sessions while projecting an unowned change', async () => {
const { ctx, control } = await harness(true)
const coldId = SessionId('session-cold-tasks')
let loaded = false
ctx.provide('sessionPersistence', {
list: async () => [{ version: 0, id: coldId, createdAt: 5, cwd: '/tmp' }],
locate: () => undefined,
load: () => { loaded = true; throw new Error('job projection must not load a cold log') },
} as never)
const abort = new AbortController()
const collected = collectJobs(control.control(abort.signal), 1, abort)
ctx.jobs.start(producer().spec)
await collected
expect(loaded).toBe(false)
expect(ctx.agents.get(coldId)).toBeUndefined()
})
it('reports empty sets when no jobs registry is composed', async () => {
const { session, control } = await harness(false)
const frame = await baseline(control)
expect(frame.value.jobs[session.id]).toEqual([])
})
it('never consumes model output while projecting a lifecycle', async () => {
const { ctx, agent, control } = await harness(true)
const abort = new AbortController()
const collected = collectJobs(control.control(abort.signal), 3, abort)
const task = producer()
const id = ctx.jobs.start({ ...task.spec, owner: agent })
ctx.jobs.kill(id, agent, 'test')
task.settle({ status: 'killed', detail: 'signal: SIGTERM' })
await collected
expect(task.reads.count).toBe(0)
})
it('never consumes model output while producing a baseline', async () => {
const { ctx, agent, control } = await harness(true)
const task = producer()
ctx.jobs.start({ ...task.spec, owner: agent })
const frame = await baseline(control)
expect(frame.value.jobs[agent.id]).toHaveLength(1)
expect(task.reads.count).toBe(0)
})
})
@@ -187,7 +187,7 @@ describe('list lifecycle', () => {
frame({
type: 'baseline',
value: {
queues: {}, jobs: {},
queues: {},
projections: { [S1]: { asOfSeq: 2, values: {} } },
},
})
@@ -200,7 +200,7 @@ describe('list lifecycle', () => {
frame({
type: 'baseline',
value: {
queues: {}, jobs: {},
queues: {},
projections: { [S1]: { asOfSeq: 2, values: { title: 'Durable' } } },
},
})
@@ -919,64 +919,3 @@ describe('completed reminder', () => {
expect(entry(manager, S2)?.completed).toBe(true)
})
})
describe('background-job mirror', () => {
const view = (over: Partial<{ id: string; status: string; label: string }> = {}) => ({
id: 'bash-1', kind: 'bash', label: 'pnpm run build', status: 'running', startedAt: 5, ...over,
})
const tasksFrame = (
sessionId: SessionId,
jobs: unknown[],
): Extract<SessionControlFrame, { type: 'jobs' }> => ({
type: 'jobs', sessionId, jobs: jobs as never,
})
it('mirrors the whole set last-wins, keyed per session, with no Session instance needed', () => {
const manager = makeManager()
manager.handleControlFrame(tasksFrame(S1, [view()]))
manager.handleControlFrame(tasksFrame(S2, [view({ id: 'pwsh-1', label: 'other' })]))
const first = manager.getListSnapshot().jobsBySession
expect(first[S1]).toEqual([view()])
expect(first[S2]?.[0]?.label).toBe('other')
// Last-wins: the newer whole set replaces, it does not merge.
manager.handleControlFrame(tasksFrame(S1, [view({ status: 'completed' })]))
expect(manager.getListSnapshot().jobsBySession[S1]).toEqual([view({ status: 'completed' })])
})
it('stores an emptied set as an absent key so absence and [] read alike', () => {
const manager = makeManager()
manager.handleControlFrame(tasksFrame(S1, [view()]))
expect(S1 in manager.getListSnapshot().jobsBySession).toBe(true)
manager.handleControlFrame(tasksFrame(S1, []))
expect(S1 in manager.getListSnapshot().jobsBySession).toBe(false)
})
it('clears the mirror when the next control baseline has no jobs', () => {
const manager = makeManager()
manager.handleControlFrame(tasksFrame(S1, [view()]))
manager.handleControlFrame({
type: 'baseline',
value: { queues: {}, jobs: {}, projections: {} },
})
expect(S1 in manager.getListSnapshot().jobsBySession).toBe(false)
})
it('drops the rows when the session is removed, whichever stream lands first', () => {
const manager = makeManager()
manager.handleSessionAdded(summary(S1, { blank: true }))
manager.handleControlFrame(tasksFrame(S1, [view()]))
manager.handleSessionRemoved(S1)
expect(S1 in manager.getListSnapshot().jobsBySession).toBe(false)
})
it('notifies list subscribers so an open header re-renders without a poll', async () => {
const manager = makeManager()
const seen = vi.fn()
manager.subscribe(seen)
manager.handleControlFrame(tasksFrame(S1, [view()]))
// The notifier batches on a microtask; the frame itself is already applied.
await Promise.resolve()
expect(seen).toHaveBeenCalled()
})
})
@@ -327,7 +327,7 @@ describe('Session Client stream adapters', () => {
it('maps the Host-wide control baseline and deltas into one snapshot stream', async () => {
const baseline: SessionControlFrame = {
type: 'baseline',
value: { queues: {}, jobs: {}, projections: {} },
value: { queues: {}, projections: {} },
}
const update: SessionControlFrame = {
type: 'queue', sessionId: 'session-1' as never, items: [],
@@ -362,7 +362,7 @@ describe('Session Client stream adapters', () => {
const baseline: SessionControlFrame = {
type: 'baseline',
value: { queues: {}, jobs: {}, projections: {} },
value: { queues: {}, projections: {} },
}
const carrierFailed = vi.fn()
const failed = vi.fn()
@@ -15,7 +15,7 @@ const OPTIONS = [{ id: 'zh', label: '中文' }, { id: 'en', label: 'English' }]
function emptySessions() {
const store = createSnapshotStore<SessionListState>(
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined })
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined })
return bindSnapshotSelector(store)
}
function emptyWorkspaces() {
@@ -194,7 +194,7 @@ const compaction = (over: Partial<CompactionSummaryNode> = {}): CompactionSummar
/** Empty sessions-list hook for the global standard-kit seat. */
function emptySessions() {
const store = createSnapshotStore<SessionListState>(
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined })
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined })
return bindSnapshotSelector(store)
}
@@ -133,7 +133,7 @@ describe('render branch tails', () => {
const chat = createChatStore().create()
chat.actions.select({ turnSeq: 1, callId: 'ghost' } satisfies SelectionTarget)
const emptyList = createSnapshotStore<SessionListState>(
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined })
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined })
const workspaces = emptyWorkspaces()
const view = render(
<DetailsPanel
@@ -194,7 +194,7 @@ describe('render branch tails', () => {
const chat = createChatStore().create()
chat.actions.select({ turnSeq: 9, callId: 'p1:code:1:code:1', toolName: 'read' } satisfies SelectionTarget)
const emptyList = createSnapshotStore<SessionListState>(
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined })
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined })
const workspaces = emptyWorkspaces()
const owners: DetailsToolOwnerProps[] = []
const view = render(
@@ -13,7 +13,7 @@ afterEach(cleanup)
function emptySessions() {
return bindSnapshotSelector(createSnapshotStore<SessionListState>({
ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined,
ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined,
}))
}
@@ -73,7 +73,7 @@ function fakeSessions(ctx: Context): { sessions: ISessions; binding: SessionBind
current: undefined,
phase: 'ready',
subagentsByParent: {},
jobsBySession: {},
currentAddress: undefined,
})
const sessions = {
@@ -18,7 +18,7 @@ afterEach(() => {
function emptySessions() {
return bindSnapshotSelector(createSnapshotStore<SessionListState>({
ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined,
ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined,
}))
}
@@ -162,7 +162,7 @@ function bench(over?: BenchOptions) {
useSessionPendingInteraction: bindSnapshotSelector(createSnapshotStore(new Map())),
useSessions: bindSnapshotSelector(createSnapshotStore<SessionListState>({
ids: [], byId: {}, current: undefined, phase: 'ready',
subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined,
subagentsByParent: {}, currentAddress: undefined,
})),
useWorkspaces: bindSnapshotSelector(createSnapshotStore({
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
@@ -49,7 +49,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled
useSession: bindSnapshotSelector(session),
useSessions: bindSnapshotSelector(createSnapshotStore({
ids: [], byId: {}, current: undefined, phase: 'ready',
subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined,
subagentsByParent: {}, currentAddress: undefined,
})),
useSessionPendingInteraction: bindSnapshotSelector(
createSnapshotStore<SessionPendingInteractionSnapshot>(new Map()),
@@ -138,7 +138,7 @@ async function scopedBench(register?: (inputTriggers: InputTriggerService) => vo
useSession: bindSnapshotSelector(sessionStore),
useSessions: bindSnapshotSelector(createSnapshotStore({
ids: [], byId: {}, current: undefined, phase: 'ready',
subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined,
subagentsByParent: {}, currentAddress: undefined,
})),
useSessionPendingInteraction: bindSnapshotSelector(
createSnapshotStore<SessionPendingInteractionSnapshot>(new Map()),
@@ -146,7 +146,7 @@ function mount(
...listed && { [SID]: childRow },
},
current: SID,
phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined,
phase: 'ready', subagentsByParent: {}, currentAddress: undefined,
})
const workspaces = createSnapshotStore<WorkspaceSnapshot>(workspaceState(workspaceRows))
const session = createSnapshotStore<SessionSnapshot>(snapshot)
@@ -40,7 +40,7 @@ function createSessionsBench(_ctx: Context): SessionsBench {
current: undefined,
phase: 'ready',
subagentsByParent: {},
jobsBySession: {},
currentAddress: undefined,
})
const bindings = new Map<SessionId, SessionBinding>()
@@ -63,7 +63,7 @@ function props(
},
current: PARENT, phase: 'ready',
subagentsByParent: value === undefined ? nested : { [PARENT]: value, ...nested },
jobsBySession: {},
currentAddress: undefined,
} satisfies SessionListState
function useSessions<T>(select: (snapshot: SessionListState) => T): T {
@@ -21,7 +21,7 @@ const COPY: Record<string, string> = {
function emptySessions() {
const store = createSnapshotStore<SessionListState>(
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined })
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined })
return bindSnapshotSelector(store)
}
function emptyWorkspaces() {
@@ -23,7 +23,7 @@ const COPY: Record<string, string> = {
/** Empty global standard-kit hooks (the row reads neither). */
function emptySessions() {
const store = createSnapshotStore<SessionListState>(
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined })
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined })
return bindSnapshotSelector(store)
}
function emptyWorkspaces() {
@@ -29,7 +29,7 @@ function listStore() {
},
current: undefined,
phase: 'ready',
subagentsByParent: {}, jobsBySession: {},
subagentsByParent: {},
currentAddress: undefined,
})
}
@@ -212,7 +212,7 @@ describe('FileMutationRow diff card', () => {
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd: '/w/app' } },
current: SID,
phase: 'ready',
subagentsByParent: {}, jobsBySession: {},
subagentsByParent: {},
currentAddress: undefined,
})
@@ -363,13 +363,13 @@ describe('DetailsPanel diff Output section', () => {
const chat = createChatStore().create()
if (selection !== null) chat.actions.select(selection)
const sessions = createSnapshotStore<SessionListState>(cwd === undefined
? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined }
? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined }
: {
ids: [SID],
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd } },
current: SID,
phase: 'ready',
subagentsByParent: {}, jobsBySession: {},
subagentsByParent: {},
currentAddress: undefined,
})
const session = createSnapshotStore(sessionSnapshot(SID))
@@ -269,7 +269,7 @@ describe('ReadImageRow keyed toolview', () => {
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd: '/w/app' } },
current: SID,
phase: 'ready',
subagentsByParent: {}, jobsBySession: {},
subagentsByParent: {},
currentAddress: undefined,
} as unknown as SessionListState)
@@ -194,7 +194,7 @@ describe('ReadRow keyed toolview', () => {
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd: '/w/app' } },
current: SID,
phase: 'ready',
subagentsByParent: {}, jobsBySession: {},
subagentsByParent: {},
currentAddress: undefined,
})
@@ -283,13 +283,13 @@ describe('DetailsPanel Output section (read)', () => {
const chat = createChatStore().create()
if (selection !== null) chat.actions.select(selection)
const sessions = createSnapshotStore<SessionListState>(cwd === undefined
? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined }
? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined }
: {
ids: [SID],
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd } },
current: SID,
phase: 'ready',
subagentsByParent: {}, jobsBySession: {},
subagentsByParent: {},
currentAddress: undefined,
})
const session = createSnapshotStore(sessionSnapshot(SID))
@@ -375,7 +375,7 @@ describe('DetailsPanel Output section (search)', () => {
if (selection !== null) chat.actions.select(selection)
const sessions = createSnapshotStore<SessionListState>({
ids: [], byId: {}, current: undefined, phase: 'ready',
subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined,
subagentsByParent: {}, currentAddress: undefined,
})
const session = createSnapshotStore(sessionSnapshot(SID))
const conversation = createSnapshotStore(conversationSnapshot())
@@ -390,7 +390,7 @@ describe('BashRow terminal card', () => {
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0 } },
current: undefined,
phase: 'ready',
subagentsByParent: {}, jobsBySession: {},
subagentsByParent: {},
currentAddress: undefined,
})
@@ -499,13 +499,13 @@ describe('DetailsPanel Output section', () => {
const chat = createChatStore().create()
if (selection !== null) chat.actions.select(selection)
const sessions = createSnapshotStore<SessionListState>(cwd === undefined
? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined }
? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined }
: {
ids: [SID],
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd } },
current: SID,
phase: 'ready',
subagentsByParent: {}, jobsBySession: {},
subagentsByParent: {},
currentAddress: undefined,
})
const session = createSnapshotStore(sessionSnapshot(SID))
@@ -705,7 +705,7 @@ describe('DetailsPanel Output section', () => {
useSessions={bindSnapshotSelector(createSnapshotStore<SessionListState>(
{
ids: [], byId: {}, current: undefined, phase: 'ready',
subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined,
subagentsByParent: {}, currentAddress: undefined,
}))}
useSessionPendingInteraction={bindSnapshotSelector(attention)}
useWorkspaces={bindSnapshotSelector(workspaces)}
@@ -222,7 +222,7 @@ describe('DetailsPanel web Output section', () => {
if (selection !== null) chat.actions.select(selection)
const sessions = createSnapshotStore<SessionListState>({
ids: [], byId: {}, current: undefined, phase: 'ready',
subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined,
subagentsByParent: {}, currentAddress: undefined,
})
const session = createSnapshotStore(sessionSnapshot(SID))
const conversation = createSnapshotStore(conversationSnapshot())
@@ -167,7 +167,7 @@ function standaloneDuration(): Pick<
/** Empty sessions-list hook; breadcrumbs therefore fall back to the raw id. */
function emptySessions() {
const store = createSnapshotStore<SessionListState>(
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined })
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined })
return bindSnapshotSelector(store)
}
@@ -48,7 +48,7 @@ const sessionList = {
current: SID,
phase: 'ready' as const,
subagentsByParent: {},
jobsBySession: {},
currentAddress: undefined,
}
const attentionState: AttentionState = new Map()
@@ -47,7 +47,7 @@ const sessionList = {
current: SID,
phase: 'ready' as const,
subagentsByParent: {},
jobsBySession: {},
currentAddress: undefined,
}
const attentionState: AttentionState = new Map()
@@ -299,7 +299,7 @@ const listState = (overrides: Partial<SessionListState> = {}): SessionListState
current: PARENT_ID,
phase: 'ready',
subagentsByParent: {},
jobsBySession: {},
currentAddress: undefined,
...overrides,
})
@@ -47,7 +47,7 @@ async function bench() {
list: {
getSnapshot: () => ({
ids: [], byId: {}, current: undefined, phase: 'ready',
subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined,
subagentsByParent: {}, currentAddress: undefined,
}),
subscribe,
},
@@ -20,7 +20,7 @@ const list = (...items: SessionSummary[]): SessionListState => ({
ids: items.map(item => item.id),
byId: Object.fromEntries(items.map(item => [item.id, item])),
current: undefined,
phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined,
phase: 'ready', subagentsByParent: {}, currentAddress: undefined,
})
const workspace = (id: string, sessionIds: string[], title = id): WorkspaceView => ({
workspaceId: wid(id), path: `/projects/${id}`, title,
@@ -32,7 +32,7 @@ const sessionState = (items: readonly SessionSummary[], overrides: Partial<Sessi
byId: Object.fromEntries(items.map(item => [item.id, item])),
current: undefined,
phase: 'ready',
subagentsByParent: {}, jobsBySession: {},
subagentsByParent: {},
currentAddress: undefined,
...overrides,
})
@@ -30,7 +30,7 @@ function hook<T>(snapshot: T) {
return function select<S>(selector: (state: T) => S): S { return selector(snapshot) }
}
const sessions: SessionListState = {
ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined,
ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined,
}
const noPendingInteraction: SessionPendingInteractionSnapshot = new Map()
const workspaceState = (items: readonly WorkspaceView[]): WorkspaceSnapshot => ({
@@ -56,7 +56,7 @@ function sessionState(
current,
phase,
subagentsByParent: {},
jobsBySession: {},
currentAddress: undefined,
}
}
@@ -216,7 +216,7 @@ export class TestSessions implements ISessions {
constructor(private readonly stabilize: Stabilizer, private readonly rootCtx: Context) {
this.list = createSnapshotStore<SessionListState>({
ids: [], byId: {}, current: undefined, phase: 'ready',
subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined,
subagentsByParent: {}, currentAddress: undefined,
})
}
-3
View File
@@ -836,9 +836,6 @@ importers:
'@deepseek-ai/dsh-file-reference':
specifier: workspace:^
version: link:../../context/file-reference
'@deepseek-ai/dsh-jobs':
specifier: workspace:^
version: link:../../jobs/jobs
'@deepseek-ai/dsh-llm':
specifier: workspace:^
version: link:../../llm/llm