@@ -308,7 +239,7 @@ function TaskItem({ row, view, expanded, now, onToggle, kill, t }: {
type="button"
className={live ? css.row : `${css.row} ${css.rowSettled}`}
aria-expanded={expanded}
- aria-label={t(expanded ? 'row.collapseAria' : 'row.expandAria', { label: row.label })}
+ aria-label={t(expanded ? 'row.collapseAria' : 'row.expandAria', { label: job.label })}
onClick={onToggle}
>
{body}
@@ -350,10 +281,10 @@ function TaskItem({ row, view, expanded, now, onToggle, kill, t }: {
?
{t('output.error', { error: view.error })}
: null}
state.jobsBySession[sessionId]) ?? NO_JOBS
- const owned = useActivity(state => state.rowsBySession[sessionId]) ?? NO_ROWS
- const unowned = useActivity(state => state.rowsBySession['']) ?? NO_ROWS
- const observedViews = useActivity(state => state.observed)
+ const observedViews = useJobOutput(state => state.observed)
const [open, setOpen] = useState(false)
const [expandedKey, setExpandedKey] = useState(undefined)
+ const [now, setNow] = useState(() => Date.now())
// Settled-section fold: an explicit user toggle wins; before one, the tail
// folds only while live work exists (a settled-only list opens expanded).
const [settledOpen, setSettledOpen] = useState(undefined)
@@ -394,7 +324,6 @@ export function ActivityListAction({ sessionId, useSessions, useActivity, observ
const [clearedKeys, setClearedKeys] = useState>(() => new Set())
// One kill affordance advances at a time: arming a row disarms any other.
const [killPhase, setKillPhase] = useState<{ key: string; state: Exclude } | undefined>(undefined)
- const [now, setNow] = useState(() => Date.now())
const rootRef = useRef(null)
const triggerRef = useRef(null)
const menuRef = useRef(null)
@@ -402,13 +331,10 @@ export function ActivityListAction({ sessionId, useSessions, useActivity, observ
// inside the viewport (the stylesheet alone cannot see the anchor offset).
const [menuShift, setMenuShift] = useState(0)
- const rows = useMemo(
- () => ordered(mergeRows(jobs, [...owned, ...unowned])),
- [jobs, owned, unowned],
- )
+ const rows = useMemo(() => ordered(jobs), [jobs])
const liveRows = useMemo(() => rows.filter(isLive), [rows])
const settledRows = useMemo(
- () => rows.filter(row => !isLive(row) && !clearedKeys.has(row.key)),
+ () => rows.filter(job => !isLive(job) && !clearedKeys.has(String(job.id))),
[rows, clearedKeys],
)
const settledExpanded = settledOpen ?? liveRows.length === 0
@@ -452,15 +378,16 @@ export function ActivityListAction({ sessionId, useSessions, useActivity, observ
// Observation follows visibility: the stream opens when an observable panel
// expands and closes when it collapses, unmounts, or the popover closes.
- const activeActivity = open && expandedKey !== undefined
- ? rows.find(row => row.key === expandedKey)?.activityId
+ const expandedRow = open && expandedKey !== undefined
+ ? rows.find(job => String(job.id) === expandedKey)
: undefined
+ const activeJob = expandedRow !== undefined && isObservable(expandedRow) ? expandedRow.id : undefined
useEffect(() => {
- if (activeActivity === undefined) return
- return observe(activeActivity)
- }, [activeActivity, observe])
+ if (activeJob === undefined) return
+ return observe(sessionId, activeJob)
+ }, [sessionId, activeJob, observe])
- // The last visible task disappearing removes this control; close first so
+ // The last visible job disappearing removes this control; close first so
// focus does not vanish from an unmounting node.
useEffect(() => {
if (visibleCount === 0 && open) setOpen(false)
@@ -468,7 +395,7 @@ export function ActivityListAction({ sessionId, useSessions, useActivity, observ
// An expanded row that left the list (owner disposal) folds its panel.
useEffect(() => {
- if (expandedKey !== undefined && !rows.some(row => row.key === expandedKey)) {
+ if (expandedKey !== undefined && !rows.some(job => String(job.id) === expandedKey)) {
setExpandedKey(undefined)
}
}, [rows, expandedKey])
@@ -490,28 +417,26 @@ export function ActivityListAction({ sessionId, useSessions, useActivity, observ
// has no button to describe any more.
useEffect(() => {
if (killPhase !== undefined
- && !rows.some(row => row.key === killPhase.key && row.jobId !== undefined && row.status === 'running')) {
+ && !rows.some(job => String(job.id) === killPhase.key && job.status === 'running')) {
setKillPhase(undefined)
}
}, [rows, killPhase])
- const pressKill = (row: TaskRow): void => {
- /* v8 ignore next -- the button only renders for job rows. */
- if (row.jobId === undefined) return
- const jobId = row.jobId
- if (killPhase?.key !== row.key || killPhase.state !== 'armed') {
- setKillPhase({ key: row.key, state: 'armed' })
+ const pressKill = (job: SessionJob): void => {
+ const key = String(job.id)
+ if (killPhase?.key !== key || killPhase.state !== 'armed') {
+ setKillPhase({ key, state: 'armed' })
return
}
- setKillPhase({ key: row.key, state: 'pending' })
- void killJob(sessionId, jobId).then((ok) => {
+ setKillPhase({ key, state: 'pending' })
+ void killJob(sessionId, key).then((ok) => {
// An admitted kill stays pending: the unary response (HTTP) and the jobs
// frames (control stream) have no cross-carrier ordering, so re-enabling
// here could offer a duplicate kill while the row still reads `running`.
// The authoritative jobs frame flips the row to `stopping`, which removes
// the button and clears the phase through the killable-set effect above.
- setKillPhase(current => current?.key === row.key && !ok
- ? { key: row.key, state: 'failed' }
+ setKillPhase(current => current?.key === key && !ok
+ ? { key, state: 'failed' }
: current)
})
}
@@ -526,10 +451,10 @@ export function ActivityListAction({ sessionId, useSessions, useActivity, observ
const clearSettled = (): void => {
setClearedKeys((current) => {
const next = new Set(current)
- for (const row of settledRows) next.add(row.key)
+ for (const job of settledRows) next.add(String(job.id))
return next
})
- if (expandedKey !== undefined && settledRows.some(row => row.key === expandedKey)) {
+ if (expandedKey !== undefined && settledRows.some(job => String(job.id) === expandedKey)) {
setExpandedKey(undefined)
}
}
@@ -541,21 +466,21 @@ export function ActivityListAction({ sessionId, useSessions, useActivity, observ
triggerRef.current?.focus()
}
- const item = (row: TaskRow) => (
- (
+ {
- setExpandedKey(current => current === row.key ? undefined : row.key)
+ setExpandedKey(current => current === String(job.id) ? undefined : String(job.id))
}}
- {...row.jobId !== undefined && row.status === 'running'
+ {...job.status === 'running'
? {
kill: {
- state: killPhase?.key === row.key ? killPhase.state : 'idle' as const,
- onPress: () => { pressKill(row) },
+ state: killPhase?.key === String(job.id) ? killPhase.state : 'idle' as const,
+ onPress: () => { pressKill(job) },
},
}
: {}}
@@ -573,7 +498,7 @@ export function ActivityListAction({ sessionId, useSessions, useActivity, observ
aria-label={countLabel}
onClick={() => {
// Sample the clock in the same commit that opens the list: the
- // mount-time value predates every task, so the first painted frame
+ // mount-time value predates every job, so the first painted frame
// would otherwise clamp a long-running row to zero until the
// open effect corrects it a frame later.
setNow(Date.now())
diff --git a/packages/client/ui-jobs/src/client/index.ts b/packages/client/ui-jobs/src/client/index.ts
new file mode 100644
index 0000000000..2894184ce1
--- /dev/null
+++ b/packages/client/ui-jobs/src/client/index.ts
@@ -0,0 +1,56 @@
+/**
+ * Background-job plugin, browser half: contributes one session-header action
+ * that renders this session's jobs. Job rows arrive through the
+ * `jobsBySession` list mirror; per-row observation streams arrive through the
+ * `jobOutput` client service. This plugin holds no transport state of its
+ * own.
+ */
+import type { Context as ClientContext } from '@deepseek-ai/cordis'
+import { JobListAction } from './JobListAction.tsx'
+import type { JobListInjected } from './JobListAction.tsx'
+import type {} from '@deepseek-ai/dsh-api-session-controller/client'
+import type {} from '@deepseek-ai/dsh-client-locale/client'
+import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
+import type {} from '@deepseek-ai/dsh-client-ui-session/client'
+import { en, NS, zh, type JobKey } from './locales.ts'
+
+declare module '@deepseek-ai/dsh-client-ui-slots' {
+ interface LocaleNamespaceMap {
+ /** Background-job list copy. */
+ 'job': JobKey
+ }
+}
+
+export type { JobListActionProps, JobListInjected } from './JobListAction.tsx'
+
+/** Required services: session state, job observation, the slot registry, and dictionaries. */
+export const inject = ['sessions', 'jobOutput', 'slots', 'locale']
+
+/**
+ * Client plugin body: register the dictionaries and the header action.
+ * @param ctx - client root context.
+ */
+export function apply(ctx: ClientContext): void {
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-jobs: dictionaries')
+ ctx.slots.inject(
+ 'conversation.session.header.actions',
+ () => ctx.slots.register({
+ name: 'conversation.session.header.actions',
+ id: 'job-list',
+ // After the subagent catalog: session lineage reads before running work.
+ order: 20,
+ locale: NS,
+ inject: (): JobListInjected => ({
+ hooks: { jobOutput: ctx.jobOutput.state },
+ observe: (sessionId, id) => ctx.jobOutput.observe(sessionId, id),
+ killJob: async (sessionId, jobId) => {
+ // The binding exists for every session whose header renders this
+ // control; the guard covers a row pressed while its scope prunes.
+ const session = ctx.sessions.binding(sessionId)?.session
+ if (session === undefined) return false
+ return (await session.killJob(jobId)).ok
+ },
+ }),
+ }, JobListAction),
+ )
+}
diff --git a/packages/client/ui-activity/src/client/locales.ts b/packages/client/ui-jobs/src/client/locales.ts
similarity index 80%
rename from packages/client/ui-activity/src/client/locales.ts
rename to packages/client/ui-jobs/src/client/locales.ts
index 18f759670f..164832cdaa 100644
--- a/packages/client/ui-activity/src/client/locales.ts
+++ b/packages/client/ui-jobs/src/client/locales.ts
@@ -1,15 +1,15 @@
-/** `activity` namespace dictionaries. */
+/** `job` namespace dictionaries. */
/** Dictionary namespace owned by this plugin. */
-export const NS = 'activity'
+export const NS = 'job'
/** Simplified Chinese dictionary (the key-set source of truth). */
export const zh = {
- 'count.live.one': '{count} 个任务进行中',
- 'count.live.other': '{count} 个任务进行中',
- 'count.idle.one': '{count} 个任务',
- 'count.idle.other': '{count} 个任务',
- 'list.aria': '任务',
+ 'count.live.one': '{count} 个后台任务运行中',
+ 'count.live.other': '{count} 个后台任务运行中',
+ 'count.idle.one': '{count} 个后台任务',
+ 'count.idle.other': '{count} 个后台任务',
+ 'list.aria': '后台任务',
'section.live': '进行中',
'section.settledCount': '已结束 {count}',
'section.clear': '清空',
@@ -46,12 +46,12 @@ export const zh = {
} as const
/** English dictionary, key-identical to the Chinese source of truth. */
-export const en: Record = {
- 'count.live.one': '{count} task running',
- 'count.live.other': '{count} tasks running',
- 'count.idle.one': '{count} task',
- 'count.idle.other': '{count} tasks',
- 'list.aria': 'Tasks',
+export const en: Record = {
+ 'count.live.one': '{count} background job running',
+ 'count.live.other': '{count} background jobs running',
+ 'count.idle.one': '{count} background job',
+ 'count.idle.other': '{count} background jobs',
+ 'list.aria': 'Background jobs',
'section.live': 'Running',
'section.settledCount': 'Finished {count}',
'section.clear': 'Clear',
@@ -87,5 +87,5 @@ export const en: Record = {
'terminal.expandAria': 'Expand {n} collapsed output lines',
}
-/** Key domain of the `activity` namespace (zh is the source of truth). */
-export type ActivityKey = keyof typeof zh
+/** Key domain of the `job` namespace (zh is the source of truth). */
+export type JobKey = keyof typeof zh
diff --git a/packages/client/ui-activity/src/css-modules.d.ts b/packages/client/ui-jobs/src/css-modules.d.ts
similarity index 100%
rename from packages/client/ui-activity/src/css-modules.d.ts
rename to packages/client/ui-jobs/src/css-modules.d.ts
diff --git a/packages/client/ui-activity/src/index.ts b/packages/client/ui-jobs/src/index.ts
similarity index 66%
rename from packages/client/ui-activity/src/index.ts
rename to packages/client/ui-jobs/src/index.ts
index f75578ddaf..457e7eeb9c 100644
--- a/packages/client/ui-activity/src/index.ts
+++ b/packages/client/ui-jobs/src/index.ts
@@ -1,8 +1,8 @@
/**
- * Node half of the live-activity list plugin. The browser half in
+ * Node half of the background-job list plugin. The browser half in
* `src/client/` owns every contribution; this entry exists so the package
* appears as an ordinary Loader row.
- * @module @deepseek-ai/dsh-client-ui-activity
+ * @module @deepseek-ai/dsh-client-ui-jobs
*/
/** Loader-visible no-op body; the browser half carries the feature. */
diff --git a/packages/client/ui-activity/src/invariant.ts b/packages/client/ui-jobs/src/invariant.ts
similarity index 70%
rename from packages/client/ui-activity/src/invariant.ts
rename to packages/client/ui-jobs/src/invariant.ts
index 22eba24c42..51ae72f998 100644
--- a/packages/client/ui-activity/src/invariant.ts
+++ b/packages/client/ui-jobs/src/invariant.ts
@@ -1,20 +1,20 @@
/**
- * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-activity`.
- * @module @deepseek-ai/dsh-client-ui-activity/invariant
+ * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-jobs`.
+ * @module @deepseek-ai/dsh-client-ui-jobs/invariant
*/
/* jscpd:ignore-start */
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
-const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-activity'
+const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-jobs'
/** Cordis companion plugin name. */
-export const name = 'client-ui-activity-invariant'
+export const name = 'client-ui-jobs-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
-/** No runtime invariant: pure presentation over the activity feed's client snapshot; the feed's own packages assert the data relations. */
+/** No runtime invariant: pure presentation over client snapshots; the registry packages assert the data relations. */
const install: InvariantInstaller = () => {}
/**
diff --git a/packages/client/ui-activity/tests/browser-plugin.client.spec.ts b/packages/client/ui-jobs/tests/browser-plugin.client.spec.ts
similarity index 77%
rename from packages/client/ui-activity/tests/browser-plugin.client.spec.ts
rename to packages/client/ui-jobs/tests/browser-plugin.client.spec.ts
index b520cdb629..571eb48aed 100644
--- a/packages/client/ui-activity/tests/browser-plugin.client.spec.ts
+++ b/packages/client/ui-jobs/tests/browser-plugin.client.spec.ts
@@ -1,5 +1,5 @@
/**
- * ui-activity plugin halves: the browser entry's dictionary and header-slot
+ * ui-jobs plugin halves: the browser entry's dictionary and header-slot
* registrations against the real SlotRegistry (with fiber teardown proving
* removal — HMR safety), the inert node entry, and the invariant companion's
* ownership reservation.
@@ -12,7 +12,7 @@ import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client'
import { apply, inject } from '../src/client/index.ts'
import { apply as applyNode } from '../src/index.ts'
-import * as ActivityInvariant from '../src/invariant.ts'
+import * as JobsInvariant from '../src/invariant.ts'
import { en, NS, zh } from '../src/client/locales.ts'
/** Slot ledger reader: entry ids currently registered in the header list. */
@@ -22,8 +22,8 @@ function headerEntryIds(ctx: Context): (string | undefined)[] {
.map(entry => entry.options.id)
}
-/** Ids handed to the stubbed feed's observe control. */
-const observed: string[] = []
+/** Observation requests handed to the stubbed job-output control. */
+const observed: [string | undefined, string][] = []
/** Job ids the stubbed bound session was asked to kill, and its scripted result. */
const kills: string[] = []
@@ -44,9 +44,12 @@ async function bench(): Promise<{ ctx: Context; fiber: ReturnType { kills.push(jobId); return killResult } } }
: undefined,
} as never)
- ctx.provide('activityFeed', {
- state: { getSnapshot: () => ({ rowsBySession: {}, observed: {}, phase: 'pending' }), subscribe: () => () => {} },
- observe: (id: string) => { observed.push(id); return () => {} },
+ ctx.provide('jobOutput', {
+ state: { getSnapshot: () => ({ observed: {} }), subscribe: () => () => {} },
+ observe: (sessionId: string | undefined, id: string) => {
+ observed.push([sessionId, id])
+ return () => {}
+ },
} as never)
// The locale plugin binds a settings scope, which reads the connection handle
// and the forwarded-event port.
@@ -63,26 +66,26 @@ async function bench(): Promise<{ ctx: Context; fiber: ReturnType {
+describe('ui-jobs browser half', () => {
it('declares the services it binds', () => {
- expect(inject).toEqual(['sessions', 'activityFeed', 'slots', 'locale'])
+ expect(inject).toEqual(['sessions', 'jobOutput', 'slots', 'locale'])
})
- it('exposes the feed source and observation control through the inject face', async () => {
+ it('exposes the output source and observation control through the inject face', async () => {
const { ctx } = await bench()
const entry = ctx.slots
.entries('conversation.session.header.actions')
- .find(candidate => candidate.options.id === 'activity-list')
+ .find(candidate => candidate.options.id === 'job-list')
const face = (entry as unknown as {
inject: () => {
- hooks: { activity: unknown }
- observe: (id: string) => () => void
+ hooks: { jobOutput: unknown }
+ observe: (sessionId: string | undefined, id: string) => () => void
killJob: (sessionId: string, jobId: string) => Promise
}
}).inject()
- expect(face.hooks.activity).toBeDefined()
- const stopper = face.observe('bash-1')
- expect(observed).toEqual(['bash-1'])
+ expect(face.hooks.jobOutput).toBeDefined()
+ const stopper = face.observe('session', 'bash-1')
+ expect(observed).toEqual([['session', 'bash-1']])
stopper()
// The kill control routes through the bound session and reports admission…
@@ -98,9 +101,9 @@ describe('ui-activity browser half', () => {
it('registers the header action, and fiber teardown removes it (HMR safety)', async () => {
const { ctx, fiber } = await bench()
- expect(headerEntryIds(ctx)).toContain('activity-list')
+ expect(headerEntryIds(ctx)).toContain('job-list')
await fiber.dispose()
- expect(headerEntryIds(ctx)).not.toContain('activity-list')
+ expect(headerEntryIds(ctx)).not.toContain('job-list')
})
it('registers both dictionaries under its own namespace and releases them with the fiber', async () => {
@@ -120,19 +123,19 @@ describe('ui-activity browser half', () => {
})
})
-describe('ui-activity node half', () => {
+describe('ui-jobs node half', () => {
it('contributes no host behavior', () => {
// The node half exists only so the plugin appears in the Loader tree.
expect(applyNode).not.toThrow()
})
})
-describe('ui-activity invariant companion', () => {
+describe('ui-jobs invariant companion', () => {
it('reserves package ownership under its declared companion name', async () => {
const ctx = new Context()
await ctx.plugin(InvariantRegistry, { enabled: true })
- await ctx.plugin(ActivityInvariant)
- expect(ActivityInvariant.name).toBe('client-ui-activity-invariant')
- expect(ActivityInvariant.inject).toEqual(['invariants'])
+ await ctx.plugin(JobsInvariant)
+ expect(JobsInvariant.name).toBe('client-ui-jobs-invariant')
+ expect(JobsInvariant.inject).toEqual(['invariants'])
})
})
diff --git a/packages/client/ui-activity/tests/activity-list-action.client.spec.tsx b/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx
similarity index 64%
rename from packages/client/ui-activity/tests/activity-list-action.client.spec.tsx
rename to packages/client/ui-jobs/tests/job-list-action.client.spec.tsx
index 92a47a073f..47a258401e 100644
--- a/packages/client/ui-activity/tests/activity-list-action.client.spec.tsx
+++ b/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx
@@ -2,12 +2,10 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
-import type {
- ActivityFeedSnapshot, ActivityId, ActivityRow, ObservedActivity,
-} from '@deepseek-ai/dsh-api-activity-controller/client'
+import type { JobOutputSnapshot, ObservedJob } from '@deepseek-ai/dsh-api-session-controller/client'
import type { SessionJob } from '@deepseek-ai/dsh-api-session-controller/types'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
-import { ActivityListAction, type ActivityListActionProps } from '../src/client/ActivityListAction.tsx'
+import { JobListAction, type JobListActionProps } from '../src/client/JobListAction.tsx'
import { zh } from '../src/client/locales.ts'
afterEach(() => {
@@ -17,26 +15,7 @@ afterEach(() => {
})
const SESSION = 'session' as SessionId
-const t: ActivityListActionProps['t'] = makeTranslate(zh)
-
-function row(over: Partial = {}): ActivityRow {
- return {
- id: 'bash-1' as ActivityId,
- kind: 'bash',
- label: 'pnpm run build',
- sessionId: SESSION,
- status: 'running',
- startedAt: 1_700_000_000_000,
- outputTotal: 0,
- ...over,
- }
-}
-
-/** A row with no owner session, as the unowned bucket carries them. */
-function unownedRow(over: Partial = {}): ActivityRow {
- const { sessionId: _drop, ...rest } = row(over)
- return rest
-}
+const t: JobListActionProps['t'] = makeTranslate(zh)
function job(over: Partial = {}): SessionJob {
return {
@@ -49,106 +28,76 @@ function job(over: Partial = {}): SessionJob {
}
}
+/** A job that declared an observation record, so its row offers a panel. */
+function recordJob(over: Partial = {}): SessionJob {
+ return job({ outputTotal: 0, ...over })
+}
+
function props(
- snapshot: Partial,
- observe: ActivityListActionProps['observe'] = () => () => {},
- jobs: readonly SessionJob[] = [],
- killJob: ActivityListActionProps['killJob'] = async () => true,
-): ActivityListActionProps {
- const state: ActivityFeedSnapshot = {
- rowsBySession: {},
- observed: {},
- phase: 'ready',
- ...snapshot,
- }
- function useActivity(select: (value: ActivityFeedSnapshot) => T): T {
- return select(state)
+ jobs: readonly SessionJob[],
+ observe: JobListActionProps['observe'] = () => () => {},
+ observed: Readonly> = {},
+ killJob: JobListActionProps['killJob'] = async () => true,
+): JobListActionProps {
+ const outputState: JobOutputSnapshot = { observed }
+ function useJobOutput(select: (value: JobOutputSnapshot) => T): T {
+ return select(outputState)
}
const sessionsState = { jobsBySession: jobs.length > 0 ? { [SESSION]: jobs } : {} }
function useSessions(select: (value: typeof sessionsState) => T): T {
return select(sessionsState)
}
- return { sessionId: SESSION, useSessions, useActivity, observe, killJob, t } as unknown as ActivityListActionProps
+ return { sessionId: SESSION, useSessions, useJobOutput, observe, killJob, t } as unknown as JobListActionProps
}
function openList(): void {
fireEvent.click(screen.getAllByRole('button')[0]!)
}
-describe('ActivityListAction visibility', () => {
- it('renders nothing while the session sees no tasks', () => {
- const { container } = render()
+describe('JobListAction visibility', () => {
+ it('renders nothing while the session sees no jobs', () => {
+ const { container } = render()
expect(container.innerHTML).toBe('')
})
- it('counts live tasks on the trigger, merging owned and unowned activity rows', () => {
- render()
- expect(screen.getByRole('button', { name: '2 个任务进行中' })).toBeDefined()
+ it('counts live jobs on the trigger', () => {
+ render()
+ expect(screen.getByRole('button', { name: '2 个后台任务运行中' })).toBeDefined()
})
- it('falls back to the total when nothing is live and hides foreign sessions', () => {
- render()
- expect(screen.getByRole('button', { name: '1 个任务' })).toBeDefined()
+ it('falls back to the total when nothing is live', () => {
+ render()
+ expect(screen.getByRole('button', { name: '1 个后台任务' })).toBeDefined()
})
})
-describe('ActivityListAction merged rows', () => {
- it('joins a job with its correlated activity: job lifecycle, activity output', () => {
- const observe = vi.fn(() => () => {})
- render()
- openList()
- const list = screen.getByRole('list', { name: zh['list.aria'] })
- // One merged row: the job's label and lifecycle word, not the activity's.
- expect(within(list).getAllByRole('listitem')).toHaveLength(1)
- expect(within(list).getByText('pnpm run build')).toBeDefined()
- expect(screen.queryByText('activity-side label')).toBeNull()
- expect(within(list).getByText('winding down')).toBeDefined()
- // Expansion observes the correlated activity's stream.
- fireEvent.click(screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'pnpm run build') }))
- expect(observe).toHaveBeenCalledWith('bash-act-7')
- })
-
- it('renders a bare job row without expansion affordances', () => {
- render( {
+ it('renders a record-less job row without expansion affordances', () => {
+ render()
- expect(screen.getByRole('button', { name: '1 个任务进行中' })).toBeDefined()
+ expect(screen.getByRole('button', { name: '1 个后台任务运行中' })).toBeDefined()
openList()
expect(screen.getByText('explore the repo')).toBeDefined()
expect(screen.queryByRole('button', { name: zh['row.expandAria'].replace('{label}', 'explore the repo') })).toBeNull()
})
- it('keeps an activity whose job is not in the projection', () => {
- render()
+ it('shows the live detail line in place of the status word', () => {
+ render()
openList()
- expect(screen.getByText('pnpm run build')).toBeDefined()
+ const list = screen.getByRole('list', { name: zh['list.aria'] })
+ expect(within(list).getByText('pnpm run build')).toBeDefined()
+ expect(within(list).getByText('winding down')).toBeDefined()
})
it('folds the settled tail behind its count while live work exists, and clears on demand', () => {
const settled = [job({ id: 'bash-2' as SessionJob['id'], label: 'settled work', status: 'completed', finishedAt: 1_700_000_012_000 })]
- const { rerender } = render()
+ render()
openList()
expect(screen.getByText(zh['section.live'])).toBeDefined()
const toggle = screen.getByRole('button', { name: zh['section.settledCount'].replace('{count}', '1') })
@@ -161,34 +110,10 @@ describe('ActivityListAction merged rows', () => {
fireEvent.click(screen.getByRole('button', { name: zh['section.clear'] }))
expect(screen.queryByText('settled work')).toBeNull()
expect(screen.queryByRole('button', { name: zh['section.clear'] })).toBeNull()
- // A live-only list keeps its own heading and no settled section.
- rerender()
- expect(screen.getByText(zh['section.live'])).toBeDefined()
- expect(screen.queryByRole('button', { name: zh['section.clear'] })).toBeNull()
- })
-
- it('clearing drops an expanded settled panel with its rows', () => {
- const observe = vi.fn(() => () => {})
- render()
- openList()
- // Nothing live: the tail is open; expand the settled row's output panel.
- fireEvent.click(screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'settled work') }))
- expect(observe).toHaveBeenCalledWith('bash-act-9')
- fireEvent.click(screen.getByRole('button', { name: zh['section.clear'] }))
- expect(screen.queryByText('settled work')).toBeNull()
- expect(screen.queryByRole('button', { name: zh['row.collapseAria'].replace('{label}', 'settled work') })).toBeNull()
})
it('opens the settled tail by default when nothing is live', () => {
- render()
openList()
@@ -196,9 +121,24 @@ describe('ActivityListAction merged rows', () => {
expect(screen.getByRole('button', { name: zh['section.settledCount'].replace('{count}', '1') }).getAttribute('aria-expanded')).toBe('true')
})
+ it('clearing drops an expanded settled panel with its rows', () => {
+ const observe = vi.fn(() => () => {})
+ render()
+ openList()
+ // Nothing live: the tail is open; expand the settled row's output panel.
+ fireEvent.click(screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'settled work') }))
+ expect(observe).toHaveBeenCalledWith(SESSION, 'bash-2')
+ fireEvent.click(screen.getByRole('button', { name: zh['section.clear'] }))
+ expect(screen.queryByText('settled work')).toBeNull()
+ expect(screen.queryByRole('button', { name: zh['row.collapseAria'].replace('{label}', 'settled work') })).toBeNull()
+ })
+
it('shows a settled duration and ticks a live one', () => {
vi.useFakeTimers({ now: 1_700_000_020_000 })
- render( {
act(() => { vi.advanceTimersByTime(1_000) })
expect(screen.getByText('6秒')).toBeDefined()
})
-})
-describe('ActivityListAction rows and observation', () => {
it('lists rows with kind, label, and detail-or-status', () => {
- render()
+ render()
openList()
fireEvent.click(screen.getByRole('button', { name: zh['section.settledCount'].replace('{count}', '1') }))
const list = screen.getByRole('list', { name: zh['list.aria'] })
@@ -238,21 +172,54 @@ describe('ActivityListAction rows and observation', () => {
expect(items[2]?.textContent).toContain('exit code: 3')
})
+ it('orders live rows first by start and settled rows newest-first with tie-breaks', () => {
+ const settled = (id: string, label: string, startedAt: number, finishedAt?: number): SessionJob =>
+ job({
+ id: id as SessionJob['id'],
+ label,
+ status: 'completed',
+ startedAt,
+ ...finishedAt !== undefined ? { finishedAt } : {},
+ })
+ render()
+ openList()
+ fireEvent.click(screen.getByRole('button', { name: zh['section.settledCount'].replace('{count}', '5') }))
+ const labels = within(screen.getByRole('list', { name: zh['list.aria'] }))
+ .getAllByRole('listitem')
+ .map(item => item.querySelector('[title]')?.getAttribute('title'))
+ .filter(title => title !== undefined && !title.startsWith('已运行') && !title.startsWith('耗时'))
+ expect(labels).toEqual([
+ 'live-2', 'live-1', 'settled-d', 'settled-a', 'settled-c', 'settled-b', 'settled-e',
+ ])
+ expect(screen.getAllByText(zh['status.completed']).length).toBeGreaterThan(0)
+ })
+})
+
+describe('JobListAction observation', () => {
it('marks killed rows, renders every output line unfolded, and copies the command', async () => {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
const lines = Array.from({ length: 24 }, (_value, index) => `line ${index + 1}`).join('\n')
- const view: ObservedActivity = {
- activityId: 'bash-1' as ActivityId,
+ const view: ObservedJob = {
+ jobId: 'bash-1' as SessionJob['id'],
text: lines,
gapBefore: false,
status: 'killed',
streaming: false,
}
- render()
+ render()
openList()
expect(screen.getByText(zh['status.killed'])).toBeDefined()
fireEvent.click(screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'pnpm run build') }))
@@ -267,47 +234,13 @@ describe('ActivityListAction rows and observation', () => {
expect(writeText).toHaveBeenCalledWith('pnpm run build')
})
- it('orders live rows first by start and settled rows newest-first with tie-breaks', () => {
- const settled = (id: string, label: string, startedAt: number, finishedAt?: number): ActivityRow =>
- row({
- id: id as ActivityId,
- label,
- status: 'completed',
- startedAt,
- ...finishedAt !== undefined ? { finishedAt } : {},
- })
- render()
- openList()
- fireEvent.click(screen.getByRole('button', { name: zh['section.settledCount'].replace('{count}', '5') }))
- const labels = within(screen.getByRole('list', { name: zh['list.aria'] }))
- .getAllByRole('listitem')
- .map(item => item.querySelector('[title]')?.getAttribute('title'))
- .filter(title => title !== undefined)
- expect(labels).toEqual([
- 'live-2', 'live-1', 'settled-d', 'settled-a', 'settled-c', 'settled-b', 'settled-e',
- ])
- expect(screen.getAllByText(zh['status.completed']).length).toBeGreaterThan(0)
- })
-
it('shifts an overflowing popover back inside the viewport and follows resizes', () => {
vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(440)
vi.spyOn(HTMLDivElement.prototype, 'getBoundingClientRect').mockReturnValue({ left: 344 } as DOMRect)
const originalWidth = window.innerWidth
Object.defineProperty(window, 'innerWidth', { value: 700, configurable: true, writable: true })
try {
- render()
+ render()
openList()
const menu = screen.getByRole('list', { name: zh['list.aria'] })
// 700 - 12 - 440 - 344 = -96: the popover moves left to keep the margin.
@@ -328,7 +261,7 @@ describe('ActivityListAction rows and observation', () => {
const originalWidth = window.innerWidth
Object.defineProperty(window, 'innerWidth', { value: 700, configurable: true, writable: true })
try {
- render()
+ render()
openList()
const menu = screen.getByRole('list', { name: zh['list.aria'] })
// max(12 - 4, min(0, 700 - 12 - 800 - 4)) = 8: clamped at the left margin.
@@ -340,39 +273,34 @@ describe('ActivityListAction rows and observation', () => {
it('ignores other keys while open and closes once the roster empties', () => {
const settledPair = [
- row({ id: 'bash-1' as ActivityId, status: 'completed', finishedAt: 2 }),
- row({ id: 'bash-2' as ActivityId, status: 'completed', finishedAt: 3 }),
+ recordJob({ id: 'bash-1' as SessionJob['id'], status: 'completed', finishedAt: 2 }),
+ recordJob({ id: 'bash-2' as SessionJob['id'], status: 'completed', finishedAt: 3 }),
]
- const { rerender } = render()
- expect(screen.getByRole('button', { name: '2 个任务' })).toBeDefined()
+ const { rerender } = render()
+ expect(screen.getByRole('button', { name: '2 个后台任务' })).toBeDefined()
openList()
const list = screen.getByRole('list', { name: zh['list.aria'] })
fireEvent.keyDown(list, { key: 'a' })
expect(screen.getByRole('list', { name: zh['list.aria'] })).toBeDefined()
// The roster emptying unmounts the control entirely; the open flag resets first.
- rerender()
+ rerender()
expect(screen.queryByRole('button')).toBeNull()
})
- it('starts observing on expand, renders the live text, and stops on collapse', () => {
+ it('starts observing on expand with the session identity, renders the live text, and stops on collapse', () => {
const stop = vi.fn()
const observe = vi.fn(() => stop)
- const view: ObservedActivity = {
- activityId: 'bash-1' as ActivityId,
+ const view: ObservedJob = {
+ jobId: 'bash-1' as SessionJob['id'],
text: 'compiling…\n',
gapBefore: false,
status: 'running',
streaming: true,
}
- render()
+ render()
openList()
fireEvent.click(screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'pnpm run build') }))
- expect(observe).toHaveBeenCalledWith('bash-1')
+ expect(observe).toHaveBeenCalledWith(SESSION, 'bash-1')
expect(screen.getByText('compiling…')).toBeDefined()
fireEvent.click(screen.getByRole('button', { name: zh['row.collapseAria'].replace('{label}', 'pnpm run build') }))
@@ -380,18 +308,15 @@ describe('ActivityListAction rows and observation', () => {
})
it('surfaces retention gaps and stream failures above the panel', () => {
- const view: ObservedActivity = {
- activityId: 'bash-1' as ActivityId,
+ const view: ObservedJob = {
+ jobId: 'bash-1' as SessionJob['id'],
text: 'tail only',
gapBefore: true,
status: 'running',
streaming: false,
error: 'connection lost',
}
- render()
+ render()
openList()
fireEvent.click(screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'pnpm run build') }))
expect(screen.getByText(zh['output.gap'])).toBeDefined()
@@ -400,9 +325,7 @@ describe('ActivityListAction rows and observation', () => {
it('stops observation when the popover closes via Escape', () => {
const stop = vi.fn()
- render( stop)} />)
+ render( stop)} />)
openList()
fireEvent.click(screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'pnpm run build') }))
fireEvent.keyDown(screen.getByRole('list', { name: zh['list.aria'] }), { key: 'Escape' })
@@ -414,36 +337,31 @@ describe('ActivityListAction rows and observation', () => {
const stop = vi.fn()
// One stable observe identity across renders, as the inject face provides.
const observe = () => stop
- const first = props({ rowsBySession: { [SESSION]: [row()] } }, observe)
- const { rerender } = render()
+ const { rerender } = render()
openList()
fireEvent.click(screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'pnpm run build') }))
- rerender()
+ rerender()
expect(stop).toHaveBeenCalledTimes(1)
expect(screen.queryByRole('button', { name: zh['row.collapseAria'].replace('{label}', 'other') })).toBeNull()
})
})
-describe('ActivityListAction human kill', () => {
+describe('JobListAction human kill', () => {
const stopTitle = (label: string): string => zh['kill.stop'].replace('{label}', label)
- it('offers the stop control only on running job rows', () => {
- render()
+ it('offers the stop control only on running rows', () => {
+ render()
openList()
- // The running job row offers it; the settled job and the standalone
- // activity row (workflow) have no kill handle.
expect(screen.getByTitle(stopTitle('pnpm run build'))).toBeDefined()
expect(screen.queryByTitle(stopTitle('done'))).toBeNull()
- expect(screen.queryByTitle(stopTitle('standalone'))).toBeNull()
})
it('arms on the first press and kills on the confirming press', async () => {
const killJob = vi.fn(async () => true)
- render()
+ render()
openList()
const stop = screen.getByTitle(stopTitle('pnpm run build'))
fireEvent.click(stop)
@@ -464,7 +382,7 @@ describe('ActivityListAction human kill', () => {
it('an armed press disarms after the confirmation window', () => {
vi.useFakeTimers()
const killJob = vi.fn(async () => true)
- render()
+ render()
openList()
const stop = screen.getByTitle(stopTitle('pnpm run build'))
fireEvent.click(stop)
@@ -478,7 +396,7 @@ describe('ActivityListAction human kill', () => {
// Fake timers from the start so the failed-hint reset arms on the fake clock.
vi.useFakeTimers()
const killJob = vi.fn(async () => false)
- render()
+ render()
openList()
const stop = screen.getByTitle(stopTitle('pnpm run build'))
fireEvent.click(stop)
@@ -490,7 +408,7 @@ describe('ActivityListAction human kill', () => {
})
it('arming a second row disarms the first', () => {
- render()
@@ -507,10 +425,10 @@ describe('ActivityListAction human kill', () => {
it('a kill resolving after another row armed leaves the newer phase alone', async () => {
let resolveKill!: (ok: boolean) => void
const killJob = vi.fn(() => new Promise((resolve) => { resolveKill = resolve }))
- render()
+ ], undefined, {}, killJob)} />)
openList()
const first = screen.getByTitle(stopTitle('pnpm run build'))
fireEvent.click(first)
@@ -527,12 +445,12 @@ describe('ActivityListAction human kill', () => {
})
it('clears an armed phase whose row stopped being killable', () => {
- const { rerender } = render()
+ const { rerender } = render()
openList()
const stop = screen.getByTitle(stopTitle('pnpm run build'))
fireEvent.click(stop)
expect(stop.getAttribute('data-kill-state')).toBe('armed')
- rerender()
+ rerender()
// The stopping row offers no kill control any more, and the stale phase is gone.
expect(screen.queryByTitle(stopTitle('pnpm run build'))).toBeNull()
expect(document.querySelector('[data-kill-state]')).toBeNull()
diff --git a/packages/client/ui-activity/tsconfig.json b/packages/client/ui-jobs/tsconfig.json
similarity index 89%
rename from packages/client/ui-activity/tsconfig.json
rename to packages/client/ui-jobs/tsconfig.json
index 562e2c2228..4b8aae7886 100644
--- a/packages/client/ui-activity/tsconfig.json
+++ b/packages/client/ui-jobs/tsconfig.json
@@ -10,7 +10,6 @@
"references": [
{ "path": "../../../vendor/cosmokit" },
{ "path": "../../../vendor/cordis" },
- { "path": "../../api/activity-controller/tsconfig.client.json" },
{ "path": "../../api/session-controller/tsconfig.client.json" },
{ "path": "../locale" },
{ "path": "../ui-conversation" },
diff --git a/packages/client/ui-activity/tsdown.config.ts b/packages/client/ui-jobs/tsdown.config.ts
similarity index 76%
rename from packages/client/ui-activity/tsdown.config.ts
rename to packages/client/ui-jobs/tsdown.config.ts
index 096771fe90..c2f3a27162 100644
--- a/packages/client/ui-activity/tsdown.config.ts
+++ b/packages/client/ui-jobs/tsdown.config.ts
@@ -1,6 +1,6 @@
import { clientBundle } from '../tsdown.client.ts'
export default clientBundle(
- '@deepseek-ai/dsh-client-ui-activity',
+ '@deepseek-ai/dsh-client-ui-jobs',
['lib/types/index.js', 'lib/types/invariant.js'],
)
diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts
index 066e930d6d..23b36a8440 100644
--- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts
+++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts
@@ -1144,8 +1144,8 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
slotInject: '',
declaredBy: 'an entry in \'conversation.session.header\' (client-ui-conversation), so it exists while that entry is mounted',
occupants: [
- 'client-ui-activity ActivityListAction id \'activity-list\'',
'client-ui-agent-preset AgentPresetLabel id \'agent-preset\'',
+ 'client-ui-jobs JobListAction id \'job-list\'',
'client-ui-schedule ScheduleCatalogAction id \'schedule-catalog\'',
'client-ui-subagent SubagentCatalogAction id \'subagent-catalog\'',
'experimental-client-ui-agent-team TeamAction id \'agent-team\'',
diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts
index 41b648d96a..4347c93b3c 100644
--- a/packages/extensions/tool-cordis/src/api-catalog.ts
+++ b/packages/extensions/tool-cordis/src/api-catalog.ts
@@ -81,68 +81,6 @@ export interface TypeApiEntry {
/** Every harness `ctx.` service, sorted by key. */
export const SERVICE_API: readonly ServiceApiEntry[] = [
- {
- key: 'activities',
- summary: 'Abstract streaming-output registry.',
- description: 'Abstract streaming-output registry. Subclass, implement the abstract methods, and load the subclass as a plugin — it registers as `ctx.activities` (one implementation per context; loading a second throws, which is cordis\' standard duplicate-service behavior).\n\nImplementations must honor these semantics:\n\n- Observation never consumes. Any number of readers hold their own absolute byte offsets; a read changes no cursor and no producer state, so the model-facing paths (`ctx.jobs.read()`, tool results) are unaffected.\n- Records outlive producer fibers. Owner disposal ends still-open records and removes them; service disposal ends and clears everything. Neither awaits a producer — the registry owns no execution resource.\n- Retention is bounded. Appends past the live cap drop the oldest retained bytes; a reader below the retained window gets a lossy read, never an error. Settlement trims retention to the settled cap.\n- Listener delivery is owner-relative: a listener registered from an unscoped context — a host composition\'s own carrier — sees every owner, while one registered under an agent composition\'s scope sees exactly the agents composed under it. Every listener is contained.',
- methods: [
- {
- signature: 'abstract open(spec: ActivityOpen): ActivityHandle',
- description: 'Validate the spec, attach owner cleanup, and atomically register one activity. A rejection leaves no id; after return the producer owns the handle and the visible set has changed.',
- parameters: [{ name: 'spec', description: 'activity identity, owner, and correlation.' }],
- returns: 'the producer face of the registered activity.',
- },
- {
- signature: 'abstract list(caller?: Agent): ActivitySnapshot[]',
- description: 'List caller-owned and unowned activities in registration order without exposing another session\'s labels or output.',
- parameters: [{ name: 'caller', description: 'reading agent; a non-agent caller sees only unowned activities.' }],
- returns: 'fresh snapshots.',
- },
- {
- signature: 'abstract get(id: ActivityId, caller?: Agent): ActivitySnapshot',
- description: 'Return a fresh snapshot. Throws for an unknown or foreign activity.',
- parameters: [{ name: 'id', description: 'activity to look up.' }, { name: 'caller', description: 'reading agent checked against the owner.' }],
- returns: 'a fresh snapshot.',
- },
- {
- signature: 'abstract read(id: ActivityId, from: number, caller?: Agent): ActivityRead',
- description: 'Read retained output from an absolute byte offset without consuming it. Resume by passing a previous read\'s `next`; a foreign offset inside a retained chunk returns that whole chunk (its `at` may precede `from`). Throws for an unknown or foreign activity or a negative or non-integer offset.',
- parameters: [{ name: 'id', description: 'activity to read.' }, { name: 'from', description: 'absolute byte offset to read from (0 for the retained head).' }, { name: 'caller', description: 'reading agent checked against the owner.' }],
- returns: 'retained chunks overlapping `[from, total)`, the resume offset, and the lossy flag.',
- },
- {
- signature: 'abstract onActivitiesChanged(listener: ActivitiesChangedListener): () => void',
- description: 'Register an effect-scoped observer of visible-set changes: opening, detail updates, settlement, owner-disposal removal, and the emptying that service disposal commits — so an observer re-reads rather than accumulating deltas. Listeners are contained and never awaited.',
- parameters: [{ name: 'listener', description: 'receives the owner whose visible set changed, or `undefined` when an unowned activity changed and every caller\'s set did.' }],
- returns: 'disposer that unregisters the listener.',
- },
- {
- signature: 'abstract onOutput(listener: ActivityOutputListener): () => void',
- description: 'Register an effect-scoped observer of stream advancement — one signal per committed append and one at settlement, carrying only the activity id. A consumer schedules a read from its own cursor; the registry never pushes payloads. Listeners are contained and never awaited.',
- parameters: [{ name: 'listener', description: 'receives the id whose stream advanced.' }],
- returns: 'disposer that unregisters the listener.',
- },
- ],
- },
- {
- key: 'activityController',
- summary: 'Host service backing the generated `ctx.remote.activity` namespace.',
- description: 'Host service backing the generated `ctx.remote.activity` namespace.',
- methods: [
- {
- signature: '@Remote({ mode: \'stream\' }) control(signal: AbortSignal): AsyncIterable',
- description: 'Stream a complete roster baseline followed by whole-bucket replacements.',
- parameters: [{ name: 'signal', description: 'generation cancellation.' }],
- returns: 'baseline followed by per-owner roster replacement frames.',
- },
- {
- signature: '@Remote({ mode: \'stream\' }) observe(request: ActivityObserveRequest, signal: AbortSignal): AsyncIterable',
- description: 'Stream one activity\'s retained output from an absolute byte offset, then its terminal status once settled and drained. Non-consuming: the model-facing cursors never observe these reads.',
- parameters: [{ name: 'request', description: 'target activity and optional resume offset.' }, { name: 'signal', description: 'generation cancellation.' }],
- returns: 'anchor, coalesced output frames, and the terminal status.',
- },
- ],
- },
{
key: 'agentDefaultModel',
summary: 'Owns the default model selection independently of any Host or transport.',
@@ -1098,7 +1036,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
{
key: 'jobs',
summary: 'Abstract background job registry.',
- description: 'Abstract background job registry. Subclass, implement the abstract methods, and load the subclass as a plugin — it registers as `ctx.jobs` (one implementation per context; loading a second throws, which is cordis\' standard duplicate-service behavior).\n\nImplementations must honor these semantics:\n\n- Registrations outlive producer and controller fibers. Owner and service disposal cancel live work and await compliant producers; a throwing teardown cancel force-fails only the record. Teardown cancellation also marks the record reported, because a record its owner is being destroyed for has no reader left.\n- Owned-job access is fenced by the owner\'s session id. Ids are predictable, so authorization — not secrecy — is the boundary.\n- Settlement is first-wins: one terminal record, released waiters, and one round of contained listener notification, even against a late producer outcome. Completion is announced last, after the record is committed and every other observer of the settlement has seen it, because a reporter may open a model turn synchronously.\n- start refuses work while no attached job controller serves the spec\'s owner, so a producer cannot start work that owner cannot collect or stop. One registry serves every composition in the process, so this question — and completion-listener delivery — is owner-relative rather than process-wide: registrations made from an unscoped context serve every owner, and registrations made under an agent composition\'s scope serve exactly the agents composed under it.',
+ description: 'Abstract background job registry. Subclass, implement the abstract methods, and load the subclass as a plugin — it registers as `ctx.jobs` (one implementation per context; loading a second throws, which is cordis\' standard duplicate-service behavior).\n\nImplementations must honor these semantics:\n\n- Registrations outlive producer and controller fibers. Owner and service disposal cancel live work and await compliant producers; a throwing teardown cancel force-fails only the record. Teardown cancellation also marks the record reported, because a record its owner is being destroyed for has no reader left.\n- Owned-job access is fenced by the owner\'s session id. Ids are predictable, so authorization — not secrecy — is the boundary.\n- Settlement is first-wins: one terminal record, released waiters, and one round of contained listener notification, even against a late producer outcome. Completion is announced last, after the record is committed and every other observer of the settlement has seen it, because a reporter may open a model turn synchronously.\n- start refuses work while no attached job controller serves the spec\'s owner, so a producer cannot start work that owner cannot collect or stop. One registry serves every composition in the process, so this question — and completion-listener delivery — is owner-relative rather than process-wide: registrations made from an unscoped context serve every owner, and registrations made under an agent composition\'s scope serve exactly the agents composed under it.\n- Record observation never consumes. Any number of readRecord readers hold their own absolute byte offsets; a record read changes no cursor and no notice state, so the model-facing surfaces (the consuming read, completion notices) are unaffected.\n- Record retention is bounded. Appends past the live cap drop the oldest retained bytes; a reader below the retained window gets a lossy read, never an error. Settlement — the producer outcome, a kill, or teardown — trims retention to the settled cap and ends the stream; the record has no separate lifecycle.',
methods: [
{
signature: 'abstract start(spec: JobStart): JobId',
@@ -1148,6 +1086,18 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
parameters: [{ name: 'listener', description: 'receives the owner whose visible set changed, or `undefined` when an unowned job changed and every caller\'s set did.' }],
returns: 'disposer that unregisters the listener.',
},
+ {
+ signature: 'abstract readRecord(id: JobId, from: number, caller?: Agent): JobRecordRead',
+ description: 'Read retained record output from an absolute byte offset without consuming it. Resume by passing a previous read\'s `next`; a foreign offset inside a retained chunk returns that whole chunk (its `at` may precede `from`). Never marks the job reported. Throws for an unknown or foreign job, a job without a JobStart.record declaration, or a negative or non-integer offset.',
+ parameters: [{ name: 'id', description: 'job to read.' }, { name: 'from', description: 'absolute byte offset to read from (0 for the retained head).' }, { name: 'caller', description: 'reading agent checked against the owner.' }],
+ returns: 'retained chunks overlapping `[from, total)`, the resume offset, and the lossy flag.',
+ },
+ {
+ signature: 'abstract onOutput(listener: JobOutputListener): () => void',
+ description: 'Register an effect-scoped observer of record advancement — one signal per committed append and one at settlement, carrying only the job id. A consumer schedules a readRecord from its own cursor; the registry never pushes payloads. Delivery is owner-relative on the same terms as onJobsChanged. Listeners are contained and never awaited.',
+ parameters: [{ name: 'listener', description: 'receives the id whose record advanced.' }],
+ returns: 'disposer that unregisters the listener.',
+ },
{
signature: 'abstract attachController(name: string): () => void',
description: 'Attach an effect-scoped controller that can read and stop jobs. It serves the owners its registering context\'s scope covers, and start refuses an owner no attached controller serves.',
@@ -1511,6 +1461,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
parameters: [{ name: 'signal', description: 'cancellation owned by the Remote stream carrier.' }],
returns: 'one complete baseline followed by live replacement frames.',
},
+ {
+ signature: '@Remote({ mode: \'stream\' }) observeJob(request: SessionObserveJobRequest, signal: AbortSignal): AsyncIterable',
+ description: 'Stream one job\'s retained record output from an absolute byte offset, then its terminal status once settled and drained. Non-consuming: the model-facing cursor and notice state never observe these reads. The request\'s session resolves the fenced-read caller; the registry rejects a job the session does not own, a job without a record, or an unknown job.',
+ parameters: [{ name: 'request', description: 'target job, owning session, and optional resume offset.' }, { name: 'signal', description: 'cancellation owned by the Remote stream carrier.' }],
+ returns: 'anchor, coalesced output frames, and the terminal status.',
+ },
],
},
{
@@ -3473,94 +3429,6 @@ export const EVENT_API: readonly EventApiEntry[] = [
/** Shapes of every exported type the Service and Event signatures reference (transitively), sorted by name. */
export const TYPE_API: readonly TypeApiEntry[] = [
- {
- name: 'ActivitiesChangedListener',
- declaration: 'export type ActivitiesChangedListener = (owner: Agent | undefined) => void;',
- },
- {
- name: 'ActivityAppendOptions',
- declaration: 'export interface ActivityAppendOptions {\n channel?: ActivityChannel;\n gapBefore?: true;\n}',
- },
- {
- name: 'ActivityChannel',
- declaration: 'export type ActivityChannel = \'stdout\' | \'stderr\';',
- },
- {
- name: 'ActivityControlFrame',
- declaration: 'export type ActivityControlFrame = {\n readonly type: \'baseline\';\n readonly activities: readonly ActivityRow[];\n} | {\n readonly type: \'rows\';\n readonly sessionId?: SessionId;\n readonly activities: readonly ActivityRow[];\n};',
- },
- {
- name: 'ActivityCorrelation',
- declaration: 'export interface ActivityCorrelation {\n callId?: ToolCallId;\n jobId?: JobId;\n}',
- },
- {
- name: 'ActivityHandle',
- declaration: 'export interface ActivityHandle {\n readonly id: ActivityId;\n append(text: string, options?: ActivityAppendOptions): void;\n updateDetail(detail: string): void;\n end(outcome: ActivityOutcome): void;\n}',
- },
- {
- name: 'ActivityId',
- declaration: 'export type ActivityId = Branded<\'ActivityId\'>;',
- },
- {
- name: 'ActivityKind',
- declaration: 'export type ActivityKind = ActivityKindMap[keyof ActivityKindMap];',
- },
- {
- name: 'ActivityKindMap',
- declaration: 'export interface ActivityKindMap {\n bash: \'bash\';\n}',
- },
- {
- name: 'ActivityObserveFrame',
- declaration: 'export type ActivityObserveFrame = {\n readonly type: \'opened\';\n readonly activityId: ActivityId;\n readonly from: number;\n readonly earliest: number;\n readonly total: number;\n readonly status: ActivityRowStatus;\n readonly detail?: string;\n} | {\n readonly type: \'output\';\n readonly chunks: readonly ActivityWireChunk[];\n readonly next: number;\n readonly lossy?: true;\n} | {\n readonly type: \'status\';\n readonly status: ActivityRowStatus;\n readonly detail?: string;\n readonly finishedAt?: number;\n};',
- },
- {
- name: 'ActivityObserveRequest',
- declaration: 'export interface ActivityObserveRequest {\n readonly activityId: ActivityId;\n readonly from?: number;\n}',
- },
- {
- name: 'ActivityOpen',
- declaration: 'export interface ActivityOpen {\n kind: ActivityKind;\n label: string;\n owner?: Agent;\n correlation?: ActivityCorrelation;\n}',
- },
- {
- name: 'ActivityOutcome',
- declaration: 'export interface ActivityOutcome {\n status: \'completed\' | \'failed\' | \'killed\';\n detail?: string;\n}',
- },
- {
- name: 'ActivityOutputChunk',
- declaration: 'export interface ActivityOutputChunk {\n at: number;\n text: string;\n channel?: ActivityChannel;\n gapBefore?: true;\n}',
- },
- {
- name: 'ActivityOutputListener',
- declaration: 'export type ActivityOutputListener = (id: ActivityId) => void;',
- },
- {
- name: 'ActivityRead',
- declaration: 'export interface ActivityRead {\n chunks: readonly ActivityOutputChunk[];\n next: number;\n lossy: boolean;\n}',
- },
- {
- name: 'ActivityRow',
- declaration: 'export interface ActivityRow {\n readonly id: ActivityId;\n readonly kind: string;\n readonly label: string;\n readonly sessionId?: SessionId;\n readonly correlation?: ActivityRowCorrelation;\n readonly status: ActivityRowStatus;\n readonly detail?: string;\n readonly startedAt: number;\n readonly finishedAt?: number;\n readonly outputTotal: number;\n}',
- },
- {
- name: 'ActivityRowCorrelation',
- declaration: 'export interface ActivityRowCorrelation {\n readonly callId?: ToolCallId;\n readonly jobId?: JobId;\n}',
- },
- {
- name: 'ActivityRowStatus',
- declaration: 'export type ActivityRowStatus = \'running\' | \'completed\' | \'failed\' | \'killed\';',
- },
- {
- name: 'ActivitySnapshot',
- declaration: 'export interface ActivitySnapshot {\n id: ActivityId;\n kind: ActivityKind;\n label: string;\n ownerSession?: SessionId;\n correlation?: ActivityCorrelation;\n status: ActivityStatus;\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n outputTotal: number;\n outputEarliest: number;\n}',
- },
- {
- name: 'ActivityStatus',
- declaration: 'export type ActivityStatus = \'running\' | \'completed\' | \'failed\' | \'killed\';',
- },
- {
- name: 'ActivityWireChunk',
- declaration: 'export interface ActivityWireChunk {\n readonly at: number;\n readonly text: string;\n readonly channel?: string;\n readonly gapBefore?: true;\n}',
- },
{
name: 'AdapterRegistrationHandle',
declaration: 'export interface AdapterRegistrationHandle {\n (): void;\n replace(providers: string[]): void;\n}',
@@ -4349,6 +4217,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'InvokeRemoteRequest',
declaration: 'export interface InvokeRemoteRequest {\n readonly namespace: string;\n readonly method: string;\n readonly args: Readonly>;\n readonly signal?: AbortSignal;\n}',
},
+ {
+ name: 'JobAppendOptions',
+ declaration: 'export interface JobAppendOptions {\n channel?: JobChannel;\n gapBefore?: true;\n}',
+ },
+ {
+ name: 'JobChannel',
+ declaration: 'export type JobChannel = \'stdout\' | \'stderr\';',
+ },
{
name: 'JobDoneListener',
declaration: 'export type JobDoneListener = (snapshot: JobSnapshot, owner: Agent | undefined) => void | PromiseLike;',
@@ -4377,21 +4253,33 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'JobOutcome',
declaration: 'export interface JobOutcome {\n status: \'completed\' | \'killed\' | \'failed\';\n detail?: string;\n output?: string;\n}',
},
+ {
+ name: 'JobOutputListener',
+ declaration: 'export type JobOutputListener = (id: JobId) => void;',
+ },
{
name: 'JobRead',
declaration: 'export interface JobRead {\n text: string;\n snapshot: JobSnapshot;\n}',
},
+ {
+ name: 'JobRecordChunk',
+ declaration: 'export interface JobRecordChunk {\n at: number;\n text: string;\n channel?: JobChannel;\n gapBefore?: true;\n}',
+ },
+ {
+ name: 'JobRecordRead',
+ declaration: 'export interface JobRecordRead {\n chunks: readonly JobRecordChunk[];\n next: number;\n lossy: boolean;\n}',
+ },
{
name: 'JobsChangedListener',
declaration: 'export type JobsChangedListener = (owner: Agent | undefined) => void;',
},
{
name: 'JobSnapshot',
- declaration: 'export interface JobSnapshot {\n id: JobId;\n kind: JobKind;\n label: string;\n outputLimitBytes?: number;\n ownerSession?: SessionId;\n status: JobStatus;\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n reported: boolean;\n}',
+ declaration: 'export interface JobSnapshot {\n id: JobId;\n kind: JobKind;\n label: string;\n outputLimitBytes?: number;\n ownerSession?: SessionId;\n status: JobStatus;\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n reported: boolean;\n outputTotal?: number;\n outputEarliest?: number;\n}',
},
{
name: 'JobStart',
- declaration: 'export interface JobStart {\n kind: JobKind;\n label: string;\n outputLimitBytes?: number;\n owner?: Agent;\n run(): JobHooks;\n}',
+ declaration: 'export interface JobStart {\n kind: JobKind;\n label: string;\n outputLimitBytes?: number;\n owner?: Agent;\n record?: true;\n run(job: RunningJob): JobHooks;\n}',
},
{
name: 'JobStatus',
@@ -4881,6 +4769,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'RunnerFailureRule',
declaration: 'export interface RunnerFailureRule {\n allowedExitCodes?: readonly number[];\n fatalSignatures: readonly string[];\n informationalLines?: readonly string[];\n}',
},
+ {
+ name: 'RunningJob',
+ declaration: 'export interface RunningJob {\n readonly id: JobId;\n append(text: string, options?: JobAppendOptions): void;\n updateDetail(detail: string): void;\n}',
+ },
{
name: 'SandboxEnforcement',
declaration: 'export type SandboxEnforcement = \'full\' | \'partial\';',
@@ -5107,7 +4999,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'SessionJob',
- declaration: 'export interface SessionJob {\n readonly id: JobId;\n readonly kind: string;\n readonly label: string;\n readonly status: \'running\' | \'stopping\' | \'completed\' | \'killed\' | \'failed\';\n readonly detail?: string;\n readonly startedAt: number;\n readonly finishedAt?: number;\n}',
+ declaration: 'export interface SessionJob {\n readonly id: JobId;\n readonly kind: string;\n readonly label: string;\n readonly status: \'running\' | \'stopping\' | \'completed\' | \'killed\' | \'failed\';\n readonly detail?: string;\n readonly startedAt: number;\n readonly finishedAt?: number;\n readonly outputTotal?: number;\n}',
+ },
+ {
+ name: 'SessionJobWireChunk',
+ declaration: 'export interface SessionJobWireChunk {\n readonly at: number;\n readonly text: string;\n readonly channel?: string;\n readonly gapBefore?: true;\n}',
},
{
name: 'SessionKillJobRequest',
@@ -5149,6 +5045,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'SessionObservationOptions',
declaration: 'export interface SessionObservationOptions {\n readonly signal?: AbortSignal;\n readonly projectionMode?: \'all\' | \'none\';\n}',
},
+ {
+ name: 'SessionObserveJobFrame',
+ declaration: 'export type SessionObserveJobFrame = {\n readonly type: \'opened\';\n readonly jobId: JobId;\n readonly from: number;\n readonly earliest: number;\n readonly total: number;\n readonly status: SessionJob[\'status\'];\n readonly detail?: string;\n} | {\n readonly type: \'output\';\n readonly chunks: readonly SessionJobWireChunk[];\n readonly next: number;\n readonly lossy?: true;\n} | {\n readonly type: \'status\';\n readonly status: SessionJob[\'status\'];\n readonly detail?: string;\n readonly finishedAt?: number;\n};',
+ },
+ {
+ name: 'SessionObserveJobRequest',
+ declaration: 'export interface SessionObserveJobRequest {\n readonly sessionId?: SessionId;\n readonly jobId: JobId;\n readonly from?: number;\n}',
+ },
{
name: 'SessionOpenWorkspacePathRequest',
declaration: 'export interface SessionOpenWorkspacePathRequest {\n readonly path: string;\n}',
diff --git a/packages/jobs/jobs-local/README.i18n.yaml b/packages/jobs/jobs-local/README.i18n.yaml
index 6e514ac7e3..e4822080f4 100644
--- a/packages/jobs/jobs-local/README.i18n.yaml
+++ b/packages/jobs/jobs-local/README.i18n.yaml
@@ -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/jobs/jobs-local/README.md
-README.md: cfbda98ccf59bcd12e931350c708cc15b30ec54c
-README.zh.md: 07acfc3c75ef730328792ab1aa45197bfcd5c849
+README.md: 2da0a0b3399ce5a55b63cad491ab6ee48a2f8a90
+README.zh.md: cc362ad7935a91a564cca5a82228934bcc15077d
diff --git a/packages/jobs/jobs-local/README.md b/packages/jobs/jobs-local/README.md
index cfbda98ccf..2da0a0b339 100644
--- a/packages/jobs/jobs-local/README.md
+++ b/packages/jobs/jobs-local/README.md
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
## Summary
-`dsh-jobs-local` runs background jobs inside the harness process: work keeps running while the agent moves on, and the owning agent can read, wait on, list, and cancel it, with completion delivered as an in-session notice when `dsh-tool-jobs` is also mounted. It implements the `dsh-jobs` contract with in-memory records handed out as fresh snapshots, never live state. A per-owner concurrency limit (default 10) bounds how many jobs one agent can have running or stopping at once; jobs die with the harness process and are not durable across restarts.
+`dsh-jobs-local` runs background jobs inside the harness process: work keeps running while the agent moves on, and the owning agent can read, wait on, list, and cancel it, with completion delivered as an in-session notice when `dsh-tool-jobs` is also mounted. It implements the `dsh-jobs` contract with in-memory records handed out as fresh snapshots, never live state. A per-owner concurrency limit (default 10) bounds how many jobs one agent can have running or stopping at once, and a job that declares a record keeps a bounded in-memory output ring (256 KiB live, trimmed to 16 KiB at settlement by default) that observers read at absolute offsets; jobs die with the harness process and are not durable across restarts.
## Table of Contents
@@ -42,8 +42,10 @@ Loading the plugin registers `ctx.jobs`; `maxConcurrentJobsPerOwner` is optional
| Field | Default | Meaning |
|---|---|---|
| `maxConcurrentJobsPerOwner` | `10` | Maximum `running` plus `stopping` jobs per exact owner, or in the shared unowned bucket |
+| `retainBytes` | `262144` | Live record retention per job, in UTF-8 bytes |
+| `settledRetainBytes` | `16384` | Record retention kept after a job settles, in UTF-8 bytes |
-The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-jobs-local) is the exhaustive source for the accepted field.
+The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-jobs-local) is the exhaustive source for the accepted fields.
### What each owner gets
@@ -69,7 +71,8 @@ This section explains the design decisions behind the registry and points at the
### Design philosophy
-- **In-memory records, fresh snapshots.** `LocalJobRegistry` keeps one `TrackedTask` per job and projects a new read-only snapshot per call; callers never receive live state.
+- **In-memory records, fresh snapshots.** `LocalJobRegistry` keeps one `TrackedTask` per job — lifecycle state plus the optional record ring — and projects a new read-only snapshot or chunk copy per call; callers never receive live state.
+- **Retention is bounded per record.** Appends past the live cap drop the oldest retained chunks (a single oversized chunk keeps its UTF-8-safe tail); a reader below the retained window gets a lossy read, never an error, and settlement trims to the settled cap.
- **Owner-relative layers, one process-wide registry.** Controllers, completion listeners, and change observers are filed into the scope that registered them (`ScopedLayers`), and reads union the global layer with the owner's scope chain — so one preset's job controls never hold `start()` open for an agent whose own composition loads none, and a settlement reaches only the listeners its owner's composition registered.
- **Preflight before start.** `start()` checks controller service, spec validity, live ownership, and capacity before invoking the producer, so a rejection leaves no job id or execution resource; registration commits without a later failable step.
- **First-wins settlement, completion last.** The earliest terminal outcome records once, releases waiters, and notifies listeners once with per-listener containment; completion is announced after the record is committed and the visible-set change published, because a reporter may open a model turn synchronously.
@@ -84,11 +87,11 @@ This section explains the design decisions behind the registry and points at the
### Scope layers
-`attachController`, `onJobDone`, and `onJobsChanged` register into the calling context's scope layer. The controller question (`servesOwner`) and listener delivery (`listenersFor`, `changedFor`) walk the same chain: global layer first, then each scoped layer along the owner's chain. Registrations are anonymous tokens so duplicate labels stay independently disposable.
+`attachController`, `onJobDone`, `onJobsChanged`, and `onOutput` register into the calling context's scope layer. The controller question (`servesOwner`) and listener delivery (`listenersFor`, `changedFor`, `outputFor`) walk the same chain: global layer first, then each scoped layer along the owner's chain. Registrations are anonymous tokens so duplicate labels stay independently disposable.
### Admission and settlement
-`activeTaskCount` counts authoritative records per exact owner or in the shared unowned bucket. `settle` marks a job reported when waiters are pending, resolves every waiter, records the terminal snapshot, announces the visible-set change, then notifies completion listeners. Pending waits mark the job reported before listeners run so completion reporters do not duplicate notices; a teardown cancel marks it for the same reason — nothing will read a notice addressed to an owner being destroyed.
+`activeTaskCount` counts authoritative records per exact owner or in the shared unowned bucket. `settle` marks a job reported when waiters are pending, trims a declared record to the settled cap, resolves every waiter, records the terminal snapshot, announces the visible-set change and the record's final output signal, then notifies completion listeners. Pending waits mark the job reported before listeners run so completion reporters do not duplicate notices; a teardown cancel marks it for the same reason — nothing will read a notice addressed to an owner being destroyed.
### Teardown
diff --git a/packages/jobs/jobs-local/README.zh.md b/packages/jobs/jobs-local/README.zh.md
index 07acfc3c75..cc362ad793 100644
--- a/packages/jobs/jobs-local/README.zh.md
+++ b/packages/jobs/jobs-local/README.zh.md
@@ -9,7 +9,7 @@ kind: "package-reference"
## 概述
-`dsh-jobs-local` 在 harness 进程内运行后台任务:工作会在 agent 继续推进的同时保持运行,拥有它的 agent 可以读取、等待、列出和取消它;同时挂载 `dsh-tool-jobs` 时,完成以会话内通知送达。它用内存记录实现 `dsh-jobs` 约定,并且只交出全新快照,从不交出实时状态。按所有者的并发上限(默认 10)约束一个 agent 同时处于运行或停止中的任务数量;任务会随 harness 进程终止而消失,无法跨重启持久。
+`dsh-jobs-local` 在 harness 进程内运行后台任务:工作会在 agent 继续推进的同时保持运行,拥有它的 agent 可以读取、等待、列出和取消它;同时挂载 `dsh-tool-jobs` 时,完成以会话内通知送达。它用内存记录实现 `dsh-jobs` 约定,并且只交出全新快照,从不交出实时状态。按所有者的并发上限(默认 10)约束一个 agent 同时处于运行或停止中的任务数量,声明了 record 的任务另持有一个有界的内存输出环(默认运行期 256 KiB、结算时裁剪到 16 KiB),供观察者按绝对偏移读取;任务会随 harness 进程终止而消失,无法跨重启持久。
## 目录
@@ -42,6 +42,8 @@ kind: "package-reference"
| 字段 | 默认值 | 含义 |
|---|---|---|
| `maxConcurrentJobsPerOwner` | `10` | 每个精确所有者,或共享的无主桶中,`running` 加 `stopping` 任务的最大数量 |
+| `retainBytes` | `262144` | 每个任务 record 的运行期保留量,UTF-8 字节 |
+| `settledRetainBytes` | `16384` | 任务结算后保留的 record 量,UTF-8 字节 |
生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-jobs-local)是每个受支持字段的穷尽式真源。
@@ -69,7 +71,8 @@ kind: "package-reference"
### 设计理念
-- **内存记录,全新快照。** `LocalJobRegistry` 为每个任务保存一条 `TrackedTask`,每次调用都投影出新的只读快照;调用方永远不会拿到实时状态。
+- **内存记录、全新快照。** `LocalJobRegistry` 为每个任务保存一份 `TrackedTask`——生命周期状态加可选的 record 环——每次调用都投影新的只读快照或块副本;调用方永远拿不到实时状态。
+- **record 保留量有界。** 追加超出运行期上限时丢弃最老的保留块(单个超限块保留其 UTF-8 安全尾部);低于保留窗口的读取得到 lossy 结果而非错误,结算时裁剪到结算上限。
- **按所有者分层,一个进程级注册表。** 控制器、完成监听器与变更观察者归档到注册方所在的 scope(`ScopedLayers`),读取把全局层与所有者的 scope 链求并集——因此某个 preset 的任务控制绝不会为自身组合未加载任何控制器的 agent 保持 `start()` 可用,一次结算也只会抵达其所有者所属组合注册的监听器。
- **启动前先预检。** `start()` 在调用生产方之前检查控制器服务、spec 有效性、仍存活的所有权与容量,因此拒绝不会留下 job id 或执行资源;注册一旦提交,后续不再有可失败步骤。
- **结算首次优先,完成最后。** 最早的终止结果只记录一次,释放等待方,并只通知监听器一次,各监听器故障单独隔离;完成在记录提交且可见集变更发布之后才宣布,因为报告方可能同步开启一个模型轮次。
@@ -84,11 +87,11 @@ kind: "package-reference"
### scope 分层
-`attachController`、`onJobDone` 与 `onJobsChanged` 注册到调用上下文所在的 scope 层。控制器问题(`servesOwner`)与监听器投递(`listenersFor`、`changedFor`)走同一条链:先是全局层,再沿所有者的链逐层。注册是无名 token,因此重复标签仍可独立释放。
+`attachController`、`onJobDone`、`onJobsChanged` 与 `onOutput` 注册到调用上下文所在的 scope 层。控制器问题(`servesOwner`)与监听器投递(`listenersFor`、`changedFor`、`outputFor`)走同一条链:先是全局层,再沿所有者的链逐层。注册是无名 token,因此重复标签仍可独立释放。
### 准入与结算
-`activeTaskCount` 按精确所有者或共享无主桶统计权威记录。`settle` 在存在挂起等待方时把任务标为已报告,解析每个等待方,记录终止快照,宣布可见集变更,然后通知完成监听器。挂起的等待会在监听器运行前把任务标为已报告,因此完成报告方不会重复通知;销毁时的取消出于同样理由标记——面向正在被销毁的所有者的通知不会有人读到。
+`activeTaskCount` 按精确所有者或共享无主桶统计权威记录。`settle` 在存在挂起等待方时把任务标为已报告,把已声明的 record 裁剪到结算保留量,解析每个等待方,记录终止快照,宣布可见集变更与 record 的最终输出信号,然后通知完成监听器。挂起的等待会在监听器运行前把任务标为已报告,因此完成报告方不会重复通知;销毁时的取消出于同样理由标记——面向正在被销毁的所有者的通知不会有人读到。
### 销毁
diff --git a/packages/jobs/jobs-local/src/index.ts b/packages/jobs/jobs-local/src/index.ts
index 51b365ca0d..cc957596fa 100644
--- a/packages/jobs/jobs-local/src/index.ts
+++ b/packages/jobs/jobs-local/src/index.ts
@@ -1,7 +1,8 @@
/**
* Process-local provider for the background-job capability seam
- * (`ctx.jobs`). It keeps every record in memory and hands out fresh
- * snapshots, never live state.
+ * (`ctx.jobs`). It keeps every record — lifecycle state plus the optional
+ * bounded output ring — in memory and hands out fresh snapshots and chunk
+ * copies, never live state.
*
* Registrations outlive producer and controller fibers. Agent or service
* disposal cancels live work and awaits compliant producers; a throwing
@@ -17,8 +18,9 @@ import type { ScopeLayer } from '@deepseek-ai/dsh-scope'
import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
import { JobRegistry, JobId } from '@deepseek-ai/dsh-jobs'
import type {
- JobDoneListener, JobKillOptions, JobKind, JobOutcome, JobRead, JobSnapshot, JobStart, JobStatus,
- JobsChangedListener,
+ JobAppendOptions, JobChannel, JobDoneListener, JobKillOptions, JobKind, JobOutcome,
+ JobOutputListener, JobRead, JobRecordChunk, JobRecordRead, JobSnapshot, JobStart, JobStatus,
+ JobsChangedListener, RunningJob,
} from '@deepseek-ai/dsh-jobs'
/** Timeout code that distinguishes a bounded wait from caller cancellation. */
@@ -27,6 +29,12 @@ export const TASK_WAIT_TIMEOUT = 'TASK_WAIT_TIMEOUT'
/** Default maximum number of active jobs in one exact-owner bucket. */
const DEFAULT_MAX_CONCURRENT_TASKS_PER_OWNER = 10
+/** Default live record retention per job, in UTF-8 bytes. */
+const DEFAULT_RETAIN_BYTES = 256 * 1024
+
+/** Default record retention kept after settlement, in UTF-8 bytes. */
+const DEFAULT_SETTLED_RETAIN_BYTES = 16 * 1024
+
/** Configuration for the process-local job registry. */
export interface Config {
/**
@@ -34,6 +42,45 @@ export interface Config {
* omission defaults to 10.
*/
maxConcurrentJobsPerOwner?: number
+ /** Live record retention per job in UTF-8 bytes; omission defaults to 262144. */
+ retainBytes?: number
+ /** Record retention kept after a job settles, in UTF-8 bytes; omission defaults to 16384. */
+ settledRetainBytes?: number
+}
+
+/** One retained record ring entry; `bytes` caches the chunk's UTF-8 length. */
+interface RingChunk {
+ at: number
+ text: string
+ bytes: number
+ channel?: JobChannel
+ gapBefore?: true
+}
+
+/** The bounded output ring behind one job's declared record. */
+interface RecordState {
+ /** Retained chunks in offset order; offsets stay absolute across eviction. */
+ chunks: RingChunk[]
+ /** Sum of the retained chunks' byte lengths. */
+ retainedBytes: number
+ /** Total UTF-8 bytes ever appended. */
+ total: number
+ /** Offset of the oldest retained byte (equals {@link total} when nothing is retained). */
+ earliest: number
+}
+
+/**
+ * Producer-written state shared between the {@link RunningJob} face and the
+ * registered record: the starter call writes through it before the commit,
+ * the same object serves the job for its whole life afterwards.
+ */
+interface ProducerState {
+ /** Bounded output ring; undefined when the spec declared no record. */
+ record: RecordState | undefined
+ /** Live progress detail until settlement replaces it with the terminal one. */
+ detail: string | undefined
+ /** The committed registry record; undefined exactly during the starter call. */
+ job: TrackedTask | undefined
}
/** The registry's mutable per-job record (never handed out — see {@link LocalJobRegistry.snapshot}). */
@@ -47,7 +94,8 @@ interface TrackedTask {
cancel: (reason?: string) => void
readOutput: (() => string) | undefined
status: JobStatus
- detail: string | undefined
+ /** Producer-shared record ring and detail line. */
+ state: ProducerState
output: string | undefined
startedAt: number
finishedAt: number | undefined
@@ -69,19 +117,39 @@ function isTerminal(status: JobStatus): boolean {
return status === 'completed' || status === 'killed' || status === 'failed'
}
+/**
+ * The UTF-8-safe tail of `text` no longer than `maxBytes`: the byte cut
+ * advances past continuation bytes so the surviving text never starts inside
+ * a code point.
+ * @param text - the oversized chunk text.
+ * @param maxBytes - positive byte budget for the surviving tail.
+ * @returns the surviving tail and its exact byte length.
+ */
+function utf8Tail(text: string, maxBytes: number): { text: string; bytes: number } {
+ const raw = Buffer.from(text, 'utf8')
+ let start = raw.length - maxBytes
+ // The loop bound proves the index is in range; the assertion only
+ // discharges noUncheckedIndexedAccess.
+ while (start < raw.length && ((raw[start] as number) & 0xC0) === 0x80) start += 1
+ const tail = raw.subarray(start)
+ return { text: tail.toString('utf8'), bytes: tail.length }
+}
+
/**
* One scope's contributions: the job controllers attached from it and the
- * completion listeners registered there. Both tables are anonymous because a
- * contribution is identified by its own disposer, never by a name a second
- * registrant could shadow.
+ * completion, change, and record-output listeners registered there. All
+ * tables are anonymous because a contribution is identified by its own
+ * disposer, never by a name a second registrant could shadow.
*/
class JobLayer implements ScopeLayer {
readonly controllers = new AnonymousEntries()
readonly listeners = new AnonymousEntries()
readonly changed = new AnonymousEntries()
+ readonly output = new AnonymousEntries()
isEmpty(): boolean {
return this.controllers.isEmpty() && this.listeners.isEmpty() && this.changed.isEmpty()
+ && this.output.isEmpty()
}
}
@@ -97,10 +165,24 @@ export class LocalJobRegistry extends JobRegistry {
.min(1)
.max(Number.MAX_SAFE_INTEGER)
.default(DEFAULT_MAX_CONCURRENT_TASKS_PER_OWNER),
+ retainBytes: z.number()
+ .step(1)
+ .min(1)
+ .max(Number.MAX_SAFE_INTEGER)
+ .default(DEFAULT_RETAIN_BYTES),
+ settledRetainBytes: z.number()
+ .step(1)
+ .min(1)
+ .max(Number.MAX_SAFE_INTEGER)
+ .default(DEFAULT_SETTLED_RETAIN_BYTES),
})
/** Schemastery-defaulted active-job limit. */
private readonly maxConcurrentJobsPerOwner: number
+ /** Schemastery-defaulted live record retention cap. */
+ private readonly retainBytes: number
+ /** Schemastery-defaulted settled record retention cap. */
+ private readonly settledRetainBytes: number
private store = new Map()
private counters = new Map()
/**
@@ -124,8 +206,10 @@ export class LocalJobRegistry extends JobRegistry {
constructor(ctx: Context, config: Config) {
super(ctx)
- // Schemastery validates and fills the default before constructing the service.
+ // Schemastery validates and fills the defaults before constructing the service.
this.maxConcurrentJobsPerOwner = (config as Required).maxConcurrentJobsPerOwner
+ this.retainBytes = (config as Required).retainBytes
+ this.settledRetainBytes = (config as Required).settledRetainBytes
this.selfCtx = ctx
ctx.effect(() => () => this.disposeAll(), 'jobs teardown')
}
@@ -149,10 +233,23 @@ export class LocalJobRegistry extends JobRegistry {
)
}
- const hooks = spec.run()
+ // The id is issued before the starter runs so the producer face can carry
+ // it; a throwing starter still leaves nothing registered — its ordinal is
+ // simply skipped.
const count = (this.counters.get(spec.kind) ?? 0) + 1
this.counters.set(spec.kind, count)
const id = JobId(`${spec.kind}-${count}`)
+ const state: ProducerState = {
+ record: spec.record === true ? { chunks: [], retainedBytes: 0, total: 0, earliest: 0 } : undefined,
+ detail: undefined,
+ job: undefined,
+ }
+ const handle: RunningJob = {
+ id,
+ append: (text, options) => { this.appendRecord(id, state, text, options) },
+ updateDetail: (detail) => { this.updateDetail(state, detail) },
+ }
+ const hooks = spec.run(handle)
let markSettled!: () => void
const settled = new Promise((resolve) => { markSettled = resolve })
@@ -165,7 +262,7 @@ export class LocalJobRegistry extends JobRegistry {
cancel: hooks.cancel.bind(hooks),
readOutput: hooks.readOutput?.bind(hooks),
status: 'running',
- detail: undefined,
+ state,
output: undefined,
startedAt: Date.now(),
finishedAt: undefined,
@@ -176,6 +273,10 @@ export class LocalJobRegistry extends JobRegistry {
waiters: 0,
waitResolvers: new Set(),
}
+ // Binding the shared producer state is the commit: writes staged inside
+ // the starter are already in `state`, and every later handle call reaches
+ // the registered record for its terminal checks and observer signals.
+ state.job = job
this.store.set(id, job)
void hooks.done.then(
@@ -303,6 +404,37 @@ export class LocalJobRegistry extends JobRegistry {
)
}
+ readRecord(id: JobId, from: number, caller?: Agent): JobRecordRead {
+ const job = this.expect(id)
+ this.assertAccess(job, caller)
+ if (!Number.isSafeInteger(from) || from < 0) {
+ throw new Error(`invalid record read offset: expected a non-negative safe integer, got ${JSON.stringify(from)}`)
+ }
+ const record = job.state.record
+ if (record === undefined) {
+ throw new Error(`job ${job.id} declared no output record`)
+ }
+ const chunks: JobRecordChunk[] = []
+ for (const chunk of record.chunks) {
+ if (chunk.at + chunk.bytes <= from) continue
+ chunks.push({
+ at: chunk.at,
+ text: chunk.text,
+ ...chunk.channel !== undefined ? { channel: chunk.channel } : {},
+ ...chunk.gapBefore !== undefined ? { gapBefore: chunk.gapBefore } : {},
+ })
+ }
+ return { chunks, next: record.total, lossy: from < record.earliest }
+ }
+
+ onOutput(listener: JobOutputListener): () => void {
+ return this.layers.effect(
+ this.ctx,
+ layer => layer.output.append(listener),
+ { label: 'jobs.onOutput()' },
+ )
+ }
+
attachController(name: string): () => void {
// One token per call keeps duplicate labels independently disposable.
const token = Symbol(name)
@@ -371,6 +503,7 @@ export class LocalJobRegistry extends JobRegistry {
/** Project a fresh read-only snapshot from the mutable record. */
private snapshot(job: TrackedTask): JobSnapshot {
const ownerSession = job.owner?.id
+ const record = job.state.record
return {
id: job.id,
kind: job.kind,
@@ -378,10 +511,11 @@ export class LocalJobRegistry extends JobRegistry {
...job.outputLimitBytes !== undefined ? { outputLimitBytes: job.outputLimitBytes } : {},
...ownerSession !== undefined ? { ownerSession } : {},
status: job.status,
- ...job.detail !== undefined ? { detail: job.detail } : {},
+ ...job.state.detail !== undefined ? { detail: job.state.detail } : {},
startedAt: job.startedAt,
...job.finishedAt !== undefined ? { finishedAt: job.finishedAt } : {},
reported: job.reported,
+ ...record !== undefined ? { outputTotal: record.total, outputEarliest: record.earliest } : {},
}
}
@@ -414,6 +548,95 @@ export class LocalJobRegistry extends JobRegistry {
}
}
+ /** The record-output observers that own `owner`'s stream signals, resolved like {@link changedFor}. */
+ private *outputFor(owner?: Agent): IterableIterator {
+ yield* this.layers.global.output.values()
+ const scope = owner === undefined ? undefined : scopeOf(owner.ctx)
+ for (const layer of this.layers.chainLayers(scope)) yield* layer.output.values()
+ }
+
+ /** Announce that one job's record advanced (append or settlement), with per-listener containment. */
+ private notifyOutput(job: TrackedTask): void {
+ for (const listener of this.outputFor(job.owner)) {
+ try {
+ listener(job.id)
+ } catch (error: unknown) {
+ this.selfCtx.logger.warn(`jobs: onOutput listener threw: ${String(error)}`)
+ }
+ }
+ }
+
+ /**
+ * Append one chunk through a producer face. A chunk against a settled job or
+ * without a record declaration is logged and dropped; an empty chunk is
+ * dropped silently. A chunk staged inside the starter call is retained and
+ * signals no observer — the registration commit publishes it.
+ */
+ private appendRecord(id: JobId, state: ProducerState, text: string, options?: JobAppendOptions): void {
+ const job = state.job
+ if (job !== undefined && isTerminal(job.status)) {
+ this.selfCtx.logger.warn(`jobs: append to settled job ${job.id} dropped`)
+ return
+ }
+ const record = state.record
+ if (record === undefined) {
+ this.selfCtx.logger.warn(`jobs: append to job ${id} without a record declaration dropped`)
+ return
+ }
+ if (text.length === 0) return
+ const bytes = Buffer.byteLength(text, 'utf8')
+ record.chunks.push({
+ at: record.total,
+ text,
+ bytes,
+ ...options?.channel !== undefined ? { channel: options.channel } : {},
+ ...options?.gapBefore !== undefined ? { gapBefore: options.gapBefore } : {},
+ })
+ record.total += bytes
+ record.retainedBytes += bytes
+ this.trimRecord(record, this.retainBytes)
+ if (job !== undefined) this.notifyOutput(job)
+ }
+
+ /**
+ * Replace the live detail line through a producer face; a write against a
+ * settled job is logged and dropped. A write staged inside the starter call
+ * seeds the registered snapshot and signals no observer.
+ */
+ private updateDetail(state: ProducerState, detail: string): void {
+ const job = state.job
+ if (job !== undefined && isTerminal(job.status)) {
+ this.selfCtx.logger.warn(`jobs: detail update on settled job ${job.id} dropped`)
+ return
+ }
+ state.detail = detail
+ if (job !== undefined) this.notifyChanged(job.owner)
+ }
+
+ /**
+ * Drop retained head chunks until the ring fits `cap`; a single oversized
+ * chunk keeps only its UTF-8-safe tail with a `gapBefore` marker. Offsets
+ * stay absolute: `earliest` advances over everything dropped.
+ */
+ private trimRecord(record: RecordState, cap: number): void {
+ while (record.retainedBytes > cap && record.chunks.length > 1) {
+ const dropped = record.chunks.shift()
+ /* v8 ignore next -- the length guard proves shift() returns a chunk; the check only satisfies noUncheckedIndexedAccess. */
+ if (dropped === undefined) break
+ record.retainedBytes -= dropped.bytes
+ }
+ const single = record.chunks.length === 1 ? record.chunks[0] : undefined
+ if (single !== undefined && single.bytes > cap) {
+ const tail = utf8Tail(single.text, cap)
+ single.at += single.bytes - tail.bytes
+ single.text = tail.text
+ single.bytes = tail.bytes
+ single.gapBefore = true
+ record.retainedBytes = tail.bytes
+ }
+ record.earliest = record.chunks[0]?.at ?? record.total
+ }
+
/**
* Record the first terminal outcome, release waiters, then announce
* completion. First-wins preserves a teardown force-failure against late
@@ -429,18 +652,31 @@ export class LocalJobRegistry extends JobRegistry {
// producer facts first (`signal: SIGTERM; cancelled by the user`). A job
// that outran its kill request (settled `completed`/`failed`) keeps the
// producer detail alone — the reason describes a kill that never landed.
- job.detail = outcome.status === 'killed' && job.killReason !== undefined
- ? (outcome.detail !== undefined ? `${outcome.detail}; ${job.killReason}` : job.killReason)
- : outcome.detail
+ // Without any terminal detail the last live progress line stays, still
+ // describing what the job was doing.
+ if (outcome.status === 'killed' && job.killReason !== undefined) {
+ job.state.detail = outcome.detail !== undefined
+ ? `${outcome.detail}; ${job.killReason}`
+ : job.killReason
+ } else if (outcome.detail !== undefined) {
+ job.state.detail = outcome.detail
+ }
job.output = outcome.output
job.finishedAt = Date.now()
if (job.waiters > 0) job.reported = true
+ // Settlement ends the record: trim to the settled cap before any observer
+ // reads the terminal snapshot.
+ const record = job.state.record
+ if (record !== undefined) this.trimRecord(record, this.settledRetainBytes)
const snapshot = this.snapshot(job)
const waitResolvers = [...job.waitResolvers]
job.waitResolvers.clear()
for (const resolveWait of waitResolvers) resolveWait()
job.markSettled()
this.notifyChanged(job.owner)
+ // The record stream ends with settlement; the signal follows the committed
+ // visible-set change and precedes completion listeners, which stay last.
+ if (record !== undefined) this.notifyOutput(job)
if (this.listenersClosed) return
for (const listener of this.listenersFor(job.owner)) {
try {
diff --git a/packages/jobs/jobs-local/tests/jobs.spec.ts b/packages/jobs/jobs-local/tests/jobs.spec.ts
index 930d2bc8d3..275fcdb724 100644
--- a/packages/jobs/jobs-local/tests/jobs.spec.ts
+++ b/packages/jobs/jobs-local/tests/jobs.spec.ts
@@ -6,7 +6,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
import { bindScopeParent, createScope, scopeOf } from '@deepseek-ai/dsh-scope'
import type { ScopeKey } from '@deepseek-ai/dsh-scope'
import { JobId } from '@deepseek-ai/dsh-jobs'
-import type { JobHooks, JobKind, JobOutcome, JobSnapshot, JobStart } from '@deepseek-ai/dsh-jobs'
+import type { JobHooks, JobKind, JobOutcome, JobSnapshot, JobStart, RunningJob } from '@deepseek-ai/dsh-jobs'
import LocalJobRegistry, { type Config as JobsConfig } from '@deepseek-ai/dsh-jobs-local'
declare module '@deepseek-ai/dsh-jobs' {
@@ -55,25 +55,39 @@ async function disposeAgentScope(agent: Agent): Promise {
await dispose()
}
-/** A controllable producer start-spec: settle its `done` on demand, record cancels. */
+/** A controllable producer start-spec: settle its `done` on demand, record cancels, expose the job face. */
function producer(overrides: Partial & JobHooks> = {}) {
let settle!: (outcome: JobOutcome) => void
let reject!: (error: unknown) => void
const cancels: (string | undefined)[] = []
- const { kind = 'bash', label = 'sleep 60', owner, outputLimitBytes, ...hookOverrides } = overrides
+ const { kind = 'bash', label = 'sleep 60', owner, outputLimitBytes, record, ...hookOverrides } = overrides
const hooks: JobHooks = {
cancel(reason) { cancels.push(reason) },
done: new Promise((res, rej) => { settle = res; reject = rej }),
...hookOverrides,
}
+ let started: RunningJob | undefined
const spec: JobStart = {
kind,
label,
...owner !== undefined ? { owner } : {},
...outputLimitBytes !== undefined ? { outputLimitBytes } : {},
- run: () => hooks,
+ ...record !== undefined ? { record } : {},
+ run: (job) => {
+ started = job
+ return hooks
+ },
+ }
+ return {
+ spec,
+ settle,
+ reject,
+ cancels,
+ job(): RunningJob {
+ if (started === undefined) throw new Error('producer not started')
+ return started
+ },
}
- return { spec, settle, reject, cancels }
}
async function harness(config: JobsConfig = {}) {
@@ -186,7 +200,7 @@ describe('LocalJobRegistry.start', () => {
for (const job of live) ctx.jobs.start(job.spec)
const blocked = producer()
- const run = vi.fn(() => blocked.spec.run())
+ const run = vi.fn((job: RunningJob) => blocked.spec.run(job))
expect(() => ctx.jobs.start({ ...blocked.spec, run }))
.toThrow('background job limit reached for this owner (limit: 10)')
expect(run).not.toHaveBeenCalled()
@@ -199,7 +213,7 @@ describe('LocalJobRegistry.start', () => {
expect(ctx.jobs.start(first.spec)).toBe('bash-1')
const blocked = producer()
- const run = vi.fn(() => blocked.spec.run())
+ const run = vi.fn((job: RunningJob) => blocked.spec.run(job))
expect(() => ctx.jobs.start({ ...blocked.spec, run }))
.toThrow('use job_kill to stop an unneeded job, wait for it to finish, then retry')
expect(run).not.toHaveBeenCalled()
@@ -717,7 +731,7 @@ describe('LocalJobRegistry owner isolation', () => {
ctx.jobs.start(current.spec) // Attach the current owner's cleanup first.
const stale = producer({ owner: staleOwner })
- const staleRun = vi.fn(() => stale.spec.run())
+ const staleRun = vi.fn((job: RunningJob) => stale.spec.run(job))
expect(() => ctx.jobs.start({ ...stale.spec, run: staleRun }))
.toThrow('is not the registered agent instance')
expect(staleRun).not.toHaveBeenCalled()
@@ -1208,3 +1222,308 @@ describe('LocalJobRegistry teardown change notifications', () => {
expect(seen).toEqual([undefined, undefined, undefined])
})
})
+
+describe('LocalJobRegistry record', () => {
+ it('appends advance absolute offsets; record reads are non-consuming and never mark reported', async () => {
+ const ctx = await harness()
+ const p = producer({ record: true })
+ const id = ctx.jobs.start(p.spec)
+ p.job().append('hello ')
+ p.job().append('world', { channel: 'stderr' })
+ const first = ctx.jobs.readRecord(id, 0)
+ expect(first.lossy).toBe(false)
+ expect(first.next).toBe(11)
+ expect(first.chunks).toEqual([
+ { at: 0, text: 'hello ' },
+ { at: 6, text: 'world', channel: 'stderr' },
+ ])
+ // A second identical read proves nothing was consumed.
+ expect(ctx.jobs.readRecord(id, 0)).toEqual(first)
+ // Resuming from `next` yields nothing until more output arrives.
+ expect(ctx.jobs.readRecord(id, first.next).chunks).toEqual([])
+ p.job().append('!')
+ expect(ctx.jobs.readRecord(id, first.next).chunks).toEqual([{ at: 11, text: '!' }])
+ const snapshot = ctx.jobs.get(id)
+ expect(snapshot.outputTotal).toBe(12)
+ expect(snapshot.outputEarliest).toBe(0)
+ // The observer surface owns no notice state: even a terminal record read
+ // leaves the completion notice due.
+ p.settle({ status: 'completed' })
+ await tick()
+ ctx.jobs.readRecord(id, 0)
+ expect(ctx.jobs.get(id).reported).toBe(false)
+ })
+
+ it('staged starter writes surface at the registration commit without observer signals', async () => {
+ const ctx = await harness()
+ const outputSignals: string[] = []
+ ctx.jobs.onOutput((id) => { outputSignals.push(String(id)) })
+ const changes: number[] = []
+ ctx.jobs.onJobsChanged(() => { changes.push(1) })
+ let settle!: (outcome: JobOutcome) => void
+ const id = ctx.jobs.start({
+ kind: 'bash',
+ label: 'staged writes',
+ record: true,
+ run: (job) => {
+ job.append('early ', { channel: 'stdout' })
+ job.updateDetail('booting')
+ return {
+ cancel() {},
+ done: new Promise((resolve) => { settle = resolve }),
+ }
+ },
+ })
+ // Only the registration commit announced anything.
+ expect(outputSignals).toEqual([])
+ expect(changes).toEqual([1])
+ const snapshot = ctx.jobs.get(id)
+ expect(snapshot.detail).toBe('booting')
+ expect(snapshot.outputTotal).toBe(6)
+ expect(ctx.jobs.readRecord(id, 0).chunks).toEqual([{ at: 0, text: 'early ', channel: 'stdout' }])
+ settle({ status: 'completed' })
+ await tick()
+ })
+
+ it('drops an empty append without waking observers', async () => {
+ const ctx = await harness()
+ const signals: string[] = []
+ ctx.jobs.onOutput((id) => { signals.push(String(id)) })
+ const p = producer({ record: true })
+ const id = ctx.jobs.start(p.spec)
+ p.job().append('')
+ expect(signals).toEqual([])
+ expect(ctx.jobs.get(id).outputTotal).toBe(0)
+ })
+
+ it('logs and drops an append without a record declaration; updateDetail still works', async () => {
+ const ctx = await harness()
+ const warn = vi.fn()
+ ctx.logger.warn = warn as never
+ const p = producer()
+ const id = ctx.jobs.start(p.spec)
+ p.job().append('nowhere to land')
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining(`append to job ${id} without a record declaration dropped`))
+ expect(() => ctx.jobs.readRecord(id, 0)).toThrow(`job ${id} declared no output record`)
+ const snapshot = ctx.jobs.get(id)
+ expect(snapshot.outputTotal).toBeUndefined()
+ expect(snapshot.outputEarliest).toBeUndefined()
+ p.job().updateDetail('7/10 items')
+ expect(ctx.jobs.get(id).detail).toBe('7/10 items')
+ })
+
+ it('rejects a negative or fractional record read offset', async () => {
+ const ctx = await harness()
+ const p = producer({ record: true })
+ const id = ctx.jobs.start(p.spec)
+ expect(() => ctx.jobs.readRecord(id, -1)).toThrow(/invalid record read offset/)
+ expect(() => ctx.jobs.readRecord(id, 0.5)).toThrow(/invalid record read offset/)
+ expect(() => ctx.jobs.readRecord(JobId('bash-99'), 0)).toThrow(/unknown job/)
+ })
+
+ it('fences record reads to the owning session', async () => {
+ const ctx = await harness()
+ const alice = stubAgent(ctx, 'alice')
+ const bob = stubAgent(ctx, 'bob')
+ ctx.agents.register(alice)
+ ctx.agents.register(bob)
+ const p = producer({ owner: alice, record: true })
+ const id = ctx.jobs.start(p.spec)
+ p.job().append('secret')
+ expect(() => ctx.jobs.readRecord(id, 0, bob)).toThrow(/belongs to another session/)
+ expect(() => ctx.jobs.readRecord(id, 0)).toThrow(/belongs to another session/)
+ expect(ctx.jobs.readRecord(id, 0, alice).chunks[0]?.text).toBe('secret')
+ })
+
+ it('evicts whole head chunks past the live cap and flags stale readers lossy', async () => {
+ const ctx = await harness({ retainBytes: 8 })
+ const p = producer({ record: true })
+ const id = ctx.jobs.start(p.spec)
+ p.job().append('aaaa')
+ p.job().append('bbbb')
+ p.job().append('cccc')
+ const read = ctx.jobs.readRecord(id, 0)
+ expect(read.lossy).toBe(true)
+ expect(read.chunks).toEqual([{ at: 4, text: 'bbbb' }, { at: 8, text: 'cccc' }])
+ expect(read.next).toBe(12)
+ const snapshot = ctx.jobs.get(id)
+ expect(snapshot.outputEarliest).toBe(4)
+ expect(snapshot.outputTotal).toBe(12)
+ // A reader at the retained boundary is not lossy.
+ expect(ctx.jobs.readRecord(id, 4).lossy).toBe(false)
+ })
+
+ it('keeps only the UTF-8-safe tail of a single oversized chunk', async () => {
+ const ctx = await harness({ retainBytes: 5 })
+ const p = producer({ record: true })
+ const id = ctx.jobs.start(p.spec)
+ // '你好' is six UTF-8 bytes; a five-byte budget must not split the second code point.
+ p.job().append('你好')
+ const read = ctx.jobs.readRecord(id, 0)
+ expect(read.lossy).toBe(true)
+ expect(read.chunks).toEqual([{ at: 3, text: '好', gapBefore: true }])
+ const snapshot = ctx.jobs.get(id)
+ expect(snapshot.outputEarliest).toBe(3)
+ expect(snapshot.outputTotal).toBe(6)
+ })
+
+ it('an exhausted byte budget leaves an empty gap-marked tail at the total offset', async () => {
+ const ctx = await harness({ retainBytes: 1 })
+ const p = producer({ record: true })
+ const id = ctx.jobs.start(p.spec)
+ // Every byte of '好' from the cut is a continuation byte, so no code point survives.
+ p.job().append('好')
+ const read = ctx.jobs.readRecord(id, 0)
+ expect(read.lossy).toBe(true)
+ expect(read.chunks).toEqual([{ at: 3, text: '', gapBefore: true }])
+ expect(ctx.jobs.get(id).outputEarliest).toBe(3)
+ })
+
+ it('returns the whole overlapped chunk for a foreign mid-chunk offset', async () => {
+ const ctx = await harness()
+ const p = producer({ record: true })
+ const id = ctx.jobs.start(p.spec)
+ p.job().append('abcdef')
+ const read = ctx.jobs.readRecord(id, 3)
+ expect(read.chunks).toEqual([{ at: 0, text: 'abcdef' }])
+ expect(read.lossy).toBe(false)
+ })
+
+ it('preserves a producer-reported gap marker through reads', async () => {
+ const ctx = await harness()
+ const p = producer({ record: true })
+ const id = ctx.jobs.start(p.spec)
+ p.job().append('tail after drop', { gapBefore: true, channel: 'stdout' })
+ expect(ctx.jobs.readRecord(id, 0).chunks).toEqual([
+ { at: 0, text: 'tail after drop', channel: 'stdout', gapBefore: true },
+ ])
+ })
+
+ it('settlement trims to the settled cap, signals once more, and drops later writes', async () => {
+ const ctx = await harness({ retainBytes: 1024, settledRetainBytes: 4 })
+ const warn = vi.fn()
+ ctx.logger.warn = warn as never
+ const signals: string[] = []
+ ctx.jobs.onOutput((id) => { signals.push(String(id)) })
+ const p = producer({ record: true })
+ const id = ctx.jobs.start(p.spec)
+ p.job().append('abcdefgh')
+ p.settle({ status: 'completed', detail: 'exit code: 0' })
+ await tick()
+ expect(signals).toEqual([String(id), String(id)])
+ const snapshot = ctx.jobs.get(id)
+ expect(snapshot).toMatchObject({ status: 'completed', detail: 'exit code: 0', outputTotal: 8, outputEarliest: 4 })
+ const read = ctx.jobs.readRecord(id, 0)
+ expect(read.lossy).toBe(true)
+ expect(read.chunks).toEqual([{ at: 4, text: 'efgh', gapBefore: true }])
+ // The stream ended with the settlement: a trailing flush cannot break teardown.
+ p.job().append('late')
+ p.job().updateDetail('late detail')
+ expect(warn.mock.calls.map(call => String(call[0]))).toEqual([
+ expect.stringContaining('append to settled job'),
+ expect.stringContaining('detail update on settled job'),
+ ])
+ expect(signals).toHaveLength(2)
+ expect(ctx.jobs.get(id).detail).toBe('exit code: 0')
+ })
+
+ it('a terminal outcome without detail keeps the last progress line', async () => {
+ const ctx = await harness()
+ const changes: number[] = []
+ const p = producer({ record: true })
+ const id = ctx.jobs.start(p.spec)
+ ctx.jobs.onJobsChanged(() => { changes.push(1) })
+ p.job().updateDetail('3/10 agents done')
+ expect(changes).toHaveLength(1)
+ expect(ctx.jobs.get(id).detail).toBe('3/10 agents done')
+ p.settle({ status: 'completed' })
+ await tick()
+ expect(ctx.jobs.get(id)).toMatchObject({ status: 'completed', detail: '3/10 agents done' })
+ })
+
+ it('a teardown-forced settlement also ends the record', async () => {
+ const ctx = await harness()
+ const warn = vi.fn()
+ ctx.logger.warn = warn as never
+ const owner = stubAgent(ctx, 'owner')
+ ctx.agents.register(owner)
+ const p = producer({ owner, record: true })
+ const id = ctx.jobs.start(p.spec)
+ p.job().append('partial')
+ const disposal = disposeAgentScope(owner)
+ p.settle({ status: 'killed' })
+ await disposal
+ expect(() => ctx.jobs.readRecord(id, 0, owner)).toThrow(/unknown job/)
+ // The producer face outlives the row; its trailing flush is dropped, not thrown.
+ p.job().append('late')
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('append to settled job'))
+ })
+
+ it('delivers onOutput to the global layer and to the owning scope only', async () => {
+ const ctx = await harness()
+ const mount = createScope(ctx, {})
+ const otherMount = createScope(ctx, {})
+ const alice = stubAgent(ctx, 'alice', scopeOf(mount.ctx))
+ const bob = stubAgent(ctx, 'bob', scopeOf(otherMount.ctx))
+ ctx.agents.register(alice)
+ ctx.agents.register(bob)
+ const globalSignals: string[] = []
+ ctx.jobs.onOutput((id) => { globalSignals.push(String(id)) })
+ const scopedSignals: string[] = []
+ await mount.ctx.plugin({
+ inject: ['jobs'],
+ apply(scopedCtx: Context) {
+ scopedCtx.jobs.onOutput((id) => { scopedSignals.push(String(id)) })
+ },
+ })
+ const pa = producer({ owner: alice, record: true })
+ const aliceId = ctx.jobs.start(pa.spec)
+ const pb = producer({ owner: bob, record: true })
+ ctx.jobs.start(pb.spec)
+ pa.job().append('for alice observers')
+ pb.job().append('for bob observers')
+ expect(globalSignals).toHaveLength(2)
+ expect(scopedSignals).toEqual([String(aliceId)])
+ })
+
+ it('contains a throwing onOutput listener without breaking the append commit', async () => {
+ const ctx = await harness()
+ const warn = vi.fn()
+ ctx.logger.warn = warn as never
+ ctx.jobs.onOutput(() => { throw new Error('output boom') })
+ const p = producer({ record: true })
+ const id = ctx.jobs.start(p.spec)
+ p.job().append('x')
+ expect(ctx.jobs.get(id).outputTotal).toBe(1)
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('onOutput listener threw'))
+ })
+
+ it('unregisters onOutput listeners with their registering fiber (HMR safety)', async () => {
+ const ctx = await harness()
+ const signals: string[] = []
+ const fiber = ctx.plugin({
+ inject: ['jobs'],
+ apply(pluginCtx: Context) {
+ pluginCtx.jobs.onOutput((id) => { signals.push(String(id)) })
+ },
+ })
+ await fiber
+ const p = producer({ record: true })
+ ctx.jobs.start(p.spec)
+ p.job().append('a')
+ expect(signals).toHaveLength(1)
+ await fiber.dispose()
+ p.job().append('b')
+ expect(signals).toHaveLength(1)
+ })
+
+ it('hands out fresh record snapshots, never live registry state', async () => {
+ const ctx = await harness()
+ const p = producer({ record: true })
+ const id = ctx.jobs.start(p.spec)
+ const before = ctx.jobs.get(id)
+ p.job().append('grow')
+ expect(before.outputTotal).toBe(0)
+ expect(ctx.jobs.get(id).outputTotal).toBe(4)
+ })
+})
diff --git a/packages/jobs/jobs/README.i18n.yaml b/packages/jobs/jobs/README.i18n.yaml
index 7c29aaae8e..b29e90f6d0 100644
--- a/packages/jobs/jobs/README.i18n.yaml
+++ b/packages/jobs/jobs/README.i18n.yaml
@@ -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/jobs/jobs/README.md
-README.md: bc607404b9dcb637115bcdced761177f187de3fa
-README.zh.md: 0a1cbb924179bdcb790adc3a955106c858fc317e
+README.md: 6a8576ea482e67ec25ad633a35d1051f6f64e14f
+README.zh.md: 7c27eea4b274d1006fa7c4491597604df69c387c
diff --git a/packages/jobs/jobs/README.md b/packages/jobs/jobs/README.md
index bc607404b9..6a8576ea48 100644
--- a/packages/jobs/jobs/README.md
+++ b/packages/jobs/jobs/README.md
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
## Summary
-`dsh-jobs` lets tools run long work as background jobs: the work gets a stable `-N` id, keeps running while the agent moves on, and the owning agent can read its output, wait for it with a timeout, or request cancellation at any time. Jobs belong to the agent session that started them, so one agent's work is never visible to another, and completion reaches the owner as an in-session notice rather than by polling. This package ships the contract only: the process-local registry lives in `dsh-jobs-local`, and the model-facing controls and completion notices live in `dsh-tool-jobs`. Load an implementation to get background jobs; without one, `ctx.jobs` does not exist and `start()` cannot run.
+`dsh-jobs` lets tools run long work as background jobs: the work gets a stable `-N` id, keeps running while the agent moves on, and the owning agent can read its output, wait for it with a timeout, or request cancellation at any time. Jobs belong to the agent session that started them, so one agent's work is never visible to another, and completion reaches the owner as an in-session notice rather than by polling. A producer can also declare a per-job observation record: an append-only bounded output stream that any number of observers (the Web client) read at absolute byte offsets without touching the model-facing consuming cursor. This package ships the contract only: the process-local registry lives in `dsh-jobs-local`, and the model-facing controls and completion notices live in `dsh-tool-jobs`. Load an implementation to get background jobs; without one, `ctx.jobs` does not exist and `start()` cannot run.
## Table of Contents
@@ -31,6 +31,8 @@ Use this package when you are composing a background-job capability or writing a
A producer registers work with a kind and a one-line label; the registry returns a `-N` id such as `bash-1`. Anyone who owns the job can read output, list jobs, wait up to a timeout for settlement, and request cancellation — each call returns a fresh snapshot of the job's status, from `running` and `stopping` to the terminal `completed`, `killed`, or `failed`. When a job settles, the owning agent is notified through the completion listener that `dsh-tool-jobs` turns into an in-session notice, so no polling is needed. A producer may attach an optional byte cap so each complete model-facing output read or completion notice stays bounded.
+A producer that declares `record: true` additionally streams raw output into the job's bounded record through the `RunningJob` face its starter receives: observers read retained chunks at absolute byte offsets and get signaled on advancement, the settlement that ends the job also ends the stream, and `updateDetail` publishes a live progress line into every snapshot. The record is invisible to the model: reads consume nothing and never touch notice state.
+
### The ownership boundary
A job belongs to the agent session that started it: another agent cannot read or stop it. Ids such as `bash-1` are predictable, so this fence is authorization, not secrecy. A job started without an owner is open to any caller and lasts until the service is disposed.
@@ -75,13 +77,14 @@ This section explains the design decisions behind the contract and points at the
| File | Role |
|---|---|
| [`src/index.ts`](src/index.ts) | Plugin entry: the abstract `JobRegistry` service and its contract |
-| [`src/types.ts`](src/types.ts) | Shared vocabulary: `JobKindMap`, `JobStart`, `JobHooks`, `JobSnapshot`, listener types |
+| [`src/types.ts`](src/types.ts) | Shared vocabulary: `JobKindMap`, `JobStart`, `JobHooks`, `RunningJob`, `JobSnapshot`, listener types |
| [`src/brand.ts`](src/brand.ts) | `JobId` branded identifier, importable without the agent dependency |
-| [`src/invariant.ts`](src/invariant.ts) | Invariant companion: validates snapshot identity, status, timestamps, and owner fields |
+| [`src/pump.ts`](src/pump.ts) | `pumpJobOutput`: poll pump copying producer offset-readers into a job record |
+| [`src/invariant.ts`](src/invariant.ts) | Invariant companion: validates snapshot identity, status, timestamps, owner, and record-offset fields |
### Service operations
-Every operation is a thin projection over the registered jobs: `get` and `list` return non-consuming snapshots, `read` advances the single stream cursor, `kill` invokes producer cancellation before changing status (claiming the terminal report by default; a caller with no model-visible channel passes `reported: false` so the completion notice stays due, and its recorded reason merges into a `killed` settlement's detail), `wait` blocks up to a timeout, and `start()` preflights access, validation, and admission before invoking the producer's `run()` once while refusing any owner no attached controller serves; listeners observe terminal records and visible-set changes at owner granularity, and `attachController` scopes controller availability to its effect lifetime. Exact signatures and behavior live in the JSDoc on [`src/index.ts`](src/index.ts) and the generated [`ctx.jobs` cordis surface](../../../docs/subsystems/jobs.md).
+Every operation is a thin projection over the registered jobs: `get` and `list` return non-consuming snapshots, `read` advances the single stream cursor, `readRecord` reads retained record chunks at an absolute offset without consuming anything, `kill` invokes producer cancellation before changing status (claiming the terminal report by default; a caller with no model-visible channel passes `reported: false` so the completion notice stays due, and its recorded reason merges into a `killed` settlement's detail), `wait` blocks up to a timeout, and `start()` preflights access, validation, and admission before invoking the producer's `run()` once while refusing any owner no attached controller serves; listeners observe terminal records, visible-set changes, and record advancement at owner granularity, and `attachController` scopes controller availability to its effect lifetime. Exact signatures and behavior live in the JSDoc on [`src/index.ts`](src/index.ts) and the generated [`ctx.jobs` cordis surface](../../../docs/subsystems/jobs.md).
@@ -118,7 +121,7 @@ No direct invalidation; the named consumers own any request-prefix changes.
These limits define when the contract is a poor fit. They are current package constraints, not a task backlog.
- **The contract is in-process** — `JobStart.run()` passes callbacks and exact `Agent` objects; a durable or cross-process backend must reshape identity, restart, ownership, and observation semantics before it can implement this seam.
-- **Stream output has one consuming cursor** — the model owns it; independent observers use the non-consuming [`dsh-activity`](../../activity/activity/README.md) plane the producers mirror into instead.
+- **Stream output has one consuming cursor** — the model owns it; independent observers read the job's declared record through the non-consuming `readRecord` instead.
- **Foreground work cannot be promoted** — producers choose foreground or background before starting.
diff --git a/packages/jobs/jobs/README.zh.md b/packages/jobs/jobs/README.zh.md
index 0a1cbb9241..7c27eea4b2 100644
--- a/packages/jobs/jobs/README.zh.md
+++ b/packages/jobs/jobs/README.zh.md
@@ -9,7 +9,7 @@ kind: "package-reference"
## 概述
-`dsh-jobs` 让工具可以把长时间工作注册为后台任务:工作获得稳定的 `-N` id,在 agent 继续推进的同时保持运行,拥有它的 agent 可以随时读取输出、带超时等待或请求取消。任务属于启动它的 agent 会话,因此一个 agent 的工作永远不会被另一个 agent 看到;完成以会话内通知而非轮询的方式送达给拥有者。本包只提供约定:进程本地注册表位于 `dsh-jobs-local`,模型侧控制与完成通知位于 `dsh-tool-jobs`。加载一个实现才能获得后台任务;没有实现时 `ctx.jobs` 不存在,`start()` 无法运行。
+`dsh-jobs` 让工具可以把长时间工作注册为后台任务:工作获得稳定的 `-N` id,在 agent 继续推进的同时保持运行,拥有它的 agent 可以随时读取输出、带超时等待或请求取消。任务属于启动它的 agent 会话,因此一个 agent 的工作永远不会被另一个 agent 看到;完成以会话内通知而非轮询的方式送达给拥有者。生产方还可以声明按 job 划分的观测 record:一条追加式的有界输出流,任意数量的观察者(Web 客户端)按绝对字节偏移读取,不触碰模型侧的消耗型游标。本包只提供约定:进程本地注册表位于 `dsh-jobs-local`,模型侧控制与完成通知位于 `dsh-tool-jobs`。加载一个实现才能获得后台任务;没有实现时 `ctx.jobs` 不存在,`start()` 无法运行。
## 目录
@@ -31,6 +31,8 @@ kind: "package-reference"
生产方以 kind 和一行标签注册工作;注册表返回 `-N` id,例如 `bash-1`。拥有任务的任何一方都可以读取输出、列出任务、带超时等待结算或请求取消——每次调用都返回任务状态的全新快照,从 `running`、`stopping` 到终止态的 `completed`、`killed` 或 `failed`。任务结算时,拥有它的 agent 会通过 `dsh-tool-jobs` 转成会话内通知的完成监听器得到通知,因此无需轮询。生产方还可以附加可选的字节上限,让每次完整的模型侧输出读取或完成通知保持有界。
+声明 `record: true` 的生产方还会通过 starter 收到的 `RunningJob` 面把原始输出流入该 job 的有界 record:观察者按绝对字节偏移读取保留块并在推进时收到信号,结束 job 的结算同时封流,`updateDetail` 把实时进度行发布进每个快照。record 对模型不可见:读取不消耗任何东西,也永不触碰通知状态。
+
### 归属边界
任务属于启动它的 agent 会话:其他 agent 无法读取或停止它。`bash-1` 这样的 id 可预测,因此这道隔离是授权,而非保密。没有所有者启动的任务对任何调用方开放,并持续到服务被释放为止。
@@ -75,13 +77,14 @@ kind: "package-reference"
| 文件 | 职责 |
|---|---|
| [`src/index.ts`](src/index.ts) | 插件入口:抽象 `JobRegistry` 服务及其约定 |
-| [`src/types.ts`](src/types.ts) | 共享词汇:`JobKindMap`、`JobStart`、`JobHooks`、`JobSnapshot`、监听器类型 |
+| [`src/types.ts`](src/types.ts) | 共享词汇:`JobKindMap`、`JobStart`、`JobHooks`、`RunningJob`、`JobSnapshot`、监听器类型 |
| [`src/brand.ts`](src/brand.ts) | `JobId` 带类型标记的标识符,无需 agent 依赖即可导入 |
-| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件:校验快照标识、状态、时间戳与所有者字段 |
+| [`src/pump.ts`](src/pump.ts) | `pumpJobOutput`:把生产方偏移读取器复制进 job record 的轮询泵 |
+| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件:校验快照标识、状态、时间戳、所有者与 record 偏移字段 |
### 服务操作
-每个操作都是已注册任务之上的薄投影:`get` 与 `list` 返回非消费式快照,`read` 推进唯一的流游标,`kill` 在改变状态前调用生产方取消(默认认领终态报告;没有模型可见渠道的调用方传 `reported: false`,完成通知因此仍欠着投递,其记录的原因会合入 `killed` 结算的 detail),`wait` 阻塞至超时,`start()` 在调用生产方 `run()` 一次之前预检访问、校验与准入,同时拒绝任何没有已附加控制器服务的所有者;监听器按所有者粒度观察终止记录与可见集变化,`attachController` 把控制器可用性限定在其 effect 生命周期内。确切签名与行为见 [`src/index.ts`](src/index.ts) 的 JSDoc 与生成的 [`ctx.jobs` cordis 接口面](../../../docs/subsystems/jobs.zh.md)。
+每个操作都是已注册任务之上的薄投影:`get` 与 `list` 返回非消费式快照,`read` 推进唯一的流游标,`readRecord` 按绝对偏移读取保留的 record 块且不消耗任何东西,`kill` 在改变状态前调用生产方取消(默认认领终态报告;没有模型可见渠道的调用方传 `reported: false`,完成通知因此仍欠着投递,其记录的原因会合入 `killed` 结算的 detail),`wait` 阻塞至超时,`start()` 在调用生产方 `run()` 一次之前预检访问、校验与准入,同时拒绝任何没有已附加控制器服务的所有者;监听器按所有者粒度观察终止记录、可见集变化与 record 推进,`attachController` 把控制器可用性限定在其 effect 生命周期内。确切签名与行为见 [`src/index.ts`](src/index.ts) 的 JSDoc 与生成的 [`ctx.jobs` cordis 接口面](../../../docs/subsystems/jobs.zh.md)。
@@ -118,7 +121,7 @@ kind: "package-reference"
这些限制说明约定何时不合适。它们是当前包约束,不是任务积压。
- **约定是进程内的**——`JobStart.run()` 传入回调和确切的 `Agent` 对象;持久化或跨进程后端必须先重塑身份、重启、所有权与观察语义,才能实现此 seam。
-- **流输出只有一个消费游标**——它属于模型;独立观察者改用生产者镜像进去的非消费 [`dsh-activity`](../../activity/activity/README.zh.md) 观察面。
+- **流输出只有一个消费游标**——它属于模型;独立观察者改用非消耗的 `readRecord` 读取 job 声明的 record。
- **前台工作无法转为后台**——生产方在启动前选择前台或后台。
diff --git a/packages/jobs/jobs/src/index.ts b/packages/jobs/jobs/src/index.ts
index 3c36593119..009c9bfe39 100644
--- a/packages/jobs/jobs/src/index.ts
+++ b/packages/jobs/jobs/src/index.ts
@@ -1,31 +1,43 @@
/**
* The background-job Service Definition (`ctx.jobs`). It owns the contract for
- * job ids, session-scoped access, lifecycle state, completion listeners, and
- * owner cleanup while producers retain their execution resources. The
- * process-local registry lives in `@deepseek-ai/dsh-jobs-local`.
+ * job ids, session-scoped access, lifecycle state, completion listeners,
+ * owner cleanup, and the optional per-job observation record — an append-only
+ * bounded output stream any number of independent observers read at absolute
+ * byte offsets, invisible to the model-facing consuming cursor — while
+ * producers retain their execution resources. The process-local registry
+ * lives in `@deepseek-ai/dsh-jobs-local`.
* @module @deepseek-ai/dsh-jobs
*/
import { Context, Service } from '@deepseek-ai/cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type {
- JobDoneListener, JobId, JobKillOptions, JobRead, JobSnapshot, JobStart, JobsChangedListener,
+ JobDoneListener, JobId, JobKillOptions, JobOutputListener, JobRead, JobRecordRead, JobSnapshot,
+ JobStart, JobsChangedListener,
} from './types.ts'
export { JobId } from './types.ts'
export type {
+ JobAppendOptions,
+ JobChannel,
JobDoneListener,
JobHooks,
JobKillOptions,
JobKind,
JobKindMap,
JobOutcome,
+ JobOutputListener,
JobRead,
+ JobRecordChunk,
+ JobRecordRead,
JobSnapshot,
JobStart,
JobStatus,
JobsChangedListener,
+ RunningJob,
} from './types.ts'
+export { pumpJobOutput } from './pump.ts'
+export type { JobPumpOptions, JobPumpRead, JobPumpSource } from './pump.ts'
declare module '@deepseek-ai/cordis' {
interface Context {
@@ -59,6 +71,15 @@ declare module '@deepseek-ai/cordis' {
* than process-wide: registrations made from an unscoped context serve
* every owner, and registrations made under an agent composition's scope
* serve exactly the agents composed under it.
+ * - Record observation never consumes. Any number of {@link readRecord}
+ * readers hold their own absolute byte offsets; a record read changes no
+ * cursor and no notice state, so the model-facing surfaces (the consuming
+ * {@link read}, completion notices) are unaffected.
+ * - Record retention is bounded. Appends past the live cap drop the oldest
+ * retained bytes; a reader below the retained window gets a lossy read,
+ * never an error. Settlement — the producer outcome, a kill, or teardown —
+ * trims retention to the settled cap and ends the stream; the record has no
+ * separate lifecycle.
*/
export abstract class JobRegistry extends Service {
constructor(ctx: Context) {
@@ -170,6 +191,31 @@ export abstract class JobRegistry extends Service {
*/
abstract onJobsChanged(listener: JobsChangedListener): () => void
+ /**
+ * Read retained record output from an absolute byte offset without consuming
+ * it. Resume by passing a previous read's `next`; a foreign offset inside a
+ * retained chunk returns that whole chunk (its `at` may precede `from`).
+ * Never marks the job reported. Throws for an unknown or foreign job, a job
+ * without a {@link JobStart.record} declaration, or a negative or
+ * non-integer offset.
+ * @param id - job to read.
+ * @param from - absolute byte offset to read from (0 for the retained head).
+ * @param caller - reading agent checked against the owner.
+ * @returns retained chunks overlapping `[from, total)`, the resume offset, and the lossy flag.
+ */
+ abstract readRecord(id: JobId, from: number, caller?: Agent): JobRecordRead
+
+ /**
+ * Register an effect-scoped observer of record advancement — one signal per
+ * committed append and one at settlement, carrying only the job id. A
+ * consumer schedules a {@link readRecord} from its own cursor; the registry
+ * never pushes payloads. Delivery is owner-relative on the same terms as
+ * {@link onJobsChanged}. Listeners are contained and never awaited.
+ * @param listener - receives the id whose record advanced.
+ * @returns disposer that unregisters the listener.
+ */
+ abstract onOutput(listener: JobOutputListener): () => void
+
/**
* Attach an effect-scoped controller that can read and stop jobs. It serves the
* owners its registering context's scope covers, and {@link start} refuses an
diff --git a/packages/jobs/jobs/src/invariant.ts b/packages/jobs/jobs/src/invariant.ts
index 5963179c00..36aca11fd8 100644
--- a/packages/jobs/jobs/src/invariant.ts
+++ b/packages/jobs/jobs/src/invariant.ts
@@ -40,6 +40,15 @@ function validateSnapshot(snapshot: JobSnapshot, owner: Agent | undefined, fail:
if (snapshot.ownerSession !== expectedOwner) {
fail(`job ${JSON.stringify(id)} ownerSession does not match its completion owner`)
}
+
+ const { outputTotal, outputEarliest } = snapshot
+ if ((outputTotal !== undefined) !== (outputEarliest !== undefined)) {
+ fail(`job ${JSON.stringify(id)} record offsets must be present together`)
+ } else if (outputTotal !== undefined && outputEarliest !== undefined
+ && (!Number.isSafeInteger(outputTotal) || !Number.isSafeInteger(outputEarliest)
+ || outputEarliest < 0 || outputEarliest > outputTotal)) {
+ fail(`job ${JSON.stringify(id)} record offsets must satisfy 0 <= outputEarliest <= outputTotal`)
+ }
}
/** Install checks over current unowned records and every terminal snapshot. */
diff --git a/packages/activity/activity/src/pump.ts b/packages/jobs/jobs/src/pump.ts
similarity index 77%
rename from packages/activity/activity/src/pump.ts
rename to packages/jobs/jobs/src/pump.ts
index 4cc1eae22d..8f56b6abc6 100644
--- a/packages/activity/activity/src/pump.ts
+++ b/packages/jobs/jobs/src/pump.ts
@@ -5,10 +5,10 @@
* @module
*/
-import type { ActivityChannel, ActivityHandle } from './types.ts'
+import type { JobChannel, RunningJob } from './types.ts'
/** One non-consuming offset read from a producer-owned stream. */
-export interface ActivityPumpRead {
+export interface JobPumpRead {
/** Text captured since the requested offset (the whole retained tail when lossy). */
text: string
/** Whole-stream offset to resume from on the next read. */
@@ -17,20 +17,20 @@ export interface ActivityPumpRead {
lossy: boolean
}
-/** One producer-owned stream the pump copies into a process. */
-export interface ActivityPumpSource {
+/** One producer-owned stream the pump copies into a job record. */
+export interface JobPumpSource {
/** Stream label attached to every chunk this source yields. */
- channel?: ActivityChannel
+ channel?: JobChannel
/**
* Read everything captured since `fromByte` without consuming it.
* @param fromByte - whole-stream offset to resume from (a prior read's `nextOffset`; 0 first).
* @returns the delta text, the next offset, and the lossy flag.
*/
- read(fromByte: number): ActivityPumpRead
+ read(fromByte: number): JobPumpRead
}
-/** Cadence and settlement for one {@link pumpActivityOutput} run. */
-export interface ActivityPumpOptions {
+/** Cadence and settlement for one {@link pumpJobOutput} run. */
+export interface JobPumpOptions {
/** Poll interval in milliseconds; a positive finite number. */
pollMs: number
/**
@@ -42,20 +42,21 @@ export interface ActivityPumpOptions {
}
/**
- * Copy producer streams into a process at a bounded cadence: drain every
+ * Copy producer streams into a job record at a bounded cadence: drain every
* source, sleep `pollMs` or until `done` settles, repeat, then drain one
* final time. A lossy source read appends its surviving tail with
- * `gapBefore`, so the discontinuity stays visible to observers. The pump
- * never calls `handle.end()` — the producer maps its own outcome.
- * @param handle - the open process receiving the copied chunks.
+ * `gapBefore`, so the discontinuity stays visible to observers. Fold the
+ * returned promise into the producer's `JobHooks.done` chain so the final
+ * drain lands before settlement trims and closes the record.
+ * @param job - the running job's producer face receiving the copied chunks.
* @param sources - producer streams, each pumped at its own offset.
* @param options - poll cadence and the producer's settlement promise.
* @returns resolves after the final post-settlement drain.
*/
-export async function pumpActivityOutput(
- handle: ActivityHandle,
- sources: readonly ActivityPumpSource[],
- options: ActivityPumpOptions,
+export async function pumpJobOutput(
+ job: RunningJob,
+ sources: readonly JobPumpSource[],
+ options: JobPumpOptions,
): Promise {
if (!Number.isFinite(options.pollMs) || options.pollMs <= 0) {
throw new Error(`invalid pump pollMs: expected a positive finite number of milliseconds, got ${JSON.stringify(options.pollMs)}`)
@@ -68,9 +69,9 @@ export async function pumpActivityOutput(
state.cursor = read.nextOffset
if (read.text.length === 0) continue
if (source.channel === undefined && !read.lossy) {
- handle.append(read.text)
+ job.append(read.text)
} else {
- handle.append(read.text, {
+ job.append(read.text, {
...source.channel !== undefined ? { channel: source.channel } : {},
...read.lossy ? { gapBefore: true as const } : {},
})
diff --git a/packages/jobs/jobs/src/types.ts b/packages/jobs/jobs/src/types.ts
index c5effe5733..fe3dbc9b97 100644
--- a/packages/jobs/jobs/src/types.ts
+++ b/packages/jobs/jobs/src/types.ts
@@ -38,6 +38,24 @@ export interface JobOutcome {
output?: string
}
+/**
+ * Record-stream label. Producers without distinct streams omit it; consumers
+ * treat an unrecognized label like an absent one.
+ */
+export type JobChannel = 'stdout' | 'stderr'
+
+/** Options for one {@link RunningJob.append}. */
+export interface JobAppendOptions {
+ /** Stream label for the chunk; omitted for producers without distinct streams. */
+ channel?: JobChannel
+ /**
+ * Marks that producer-side bytes between the previous chunk and this one
+ * were lost (a source window slid before the producer could copy it), so an
+ * observer can render the discontinuity instead of a silent splice.
+ */
+ gapBefore?: true
+}
+
/**
* Producer declaration passed to {@link JobRegistry.start}. The runtime
* preflights access and cleanup before invoking {@link run}; the producer owns
@@ -50,7 +68,9 @@ export interface JobStart {
label: string
/**
* Optional UTF-8 byte cap for each complete model-facing completion notice or
- * output read, including controller status metadata.
+ * output read, including controller status metadata. Independent of record
+ * retention: it bounds the consuming model surface, never
+ * {@link JobRegistry.readRecord}.
*/
outputLimitBytes?: number
/**
@@ -61,11 +81,49 @@ export interface JobStart {
*/
owner?: Agent
/**
- * Start the work after preflight and synchronously return its hooks. Called
- * once; a throw leaves nothing registered, and the producer must clean up any
- * partially started resources.
+ * Declares an observable output record beside the model-facing surfaces:
+ * {@link RunningJob.append} retains chunks in a bounded ring that any number
+ * of observers read at absolute byte offsets through
+ * {@link JobRegistry.readRecord}, and snapshots carry
+ * {@link JobSnapshot.outputTotal} and {@link JobSnapshot.outputEarliest}.
+ * Without the declaration `append` logs and drops.
*/
- run(): JobHooks
+ record?: true
+ /**
+ * Start the work after preflight and synchronously return its hooks. Called
+ * once with the job's producer face; a throw leaves nothing registered (the
+ * spent ordinal is skipped), and the producer must clean up any partially
+ * started resources.
+ * @param job - the issued id plus the record append and live-detail writers.
+ */
+ run(job: RunningJob): JobHooks
+}
+
+/**
+ * Producer face of one registered job, handed to {@link JobStart.run} and
+ * valid for the job's whole life. All methods are synchronous. Writes staged
+ * inside the starter call are retained and become visible with the
+ * registration commit; after settlement — the producer's own outcome, a kill,
+ * or a registry-forced teardown end — both methods log and drop instead of
+ * throwing, so a producer's trailing flush cannot break its own teardown path.
+ */
+export interface RunningJob {
+ /** The registry-issued id (`-N`). */
+ readonly id: JobId
+ /**
+ * Append one record chunk. Offsets advance by the chunk's UTF-8 byte
+ * length; an empty chunk is dropped without waking observers. Without a
+ * {@link JobStart.record} declaration the chunk is logged and dropped.
+ * @param text - the chunk text, exactly as produced.
+ * @param options - stream label and gap marker.
+ */
+ append(text: string, options?: JobAppendOptions): void
+ /**
+ * Replace the snapshot's status detail with a live progress line (`3/10`).
+ * Works for every job, with or without a record.
+ * @param detail - the new detail line.
+ */
+ updateDetail(detail: string): void
}
/** Hooks through which the runtime controls and observes producer work. */
@@ -79,13 +137,16 @@ export interface JobHooks {
* Resolves after the producer releases its resources, not merely when work
* finishes. Must not reject; the runtime converts a rejection to `failed`.
* If teardown cancellation throws, the runtime may force-fail only the
- * registry record without claiming that the work stopped.
+ * registry record without claiming that the work stopped. Settlement also
+ * ends the record: fold any record-filling pump into this promise so the
+ * final drain lands before the registry trims and closes the stream.
*/
done: Promise
/**
* Consume output produced since the previous call. The producer formats
* truncation and spill notices. Absence marks a final-output-only job; each
- * job has one consuming cursor.
+ * job has one consuming cursor. Independent of the record: this is the
+ * model-facing projection, {@link JobRegistry.readRecord} the observer one.
*/
readOutput?(): string
}
@@ -111,7 +172,10 @@ export interface JobSnapshot {
ownerSession?: SessionId
/** Current lifecycle state. */
status: JobStatus
- /** Kind-specific status detail, present once the producer supplied one (usually terminal). */
+ /**
+ * Kind-specific status detail: live progress while the producer updates it
+ * through {@link RunningJob.updateDetail}, the terminal detail once settled.
+ */
detail?: string
/** Epoch ms when the job was registered. */
startedAt: number
@@ -125,6 +189,17 @@ export interface JobSnapshot {
* otherwise spend a model request per teardown layer.
*/
reported: boolean
+ /**
+ * Total UTF-8 bytes ever appended to the record — the offset the next chunk
+ * starts at. Present exactly when the job declared {@link JobStart.record}.
+ */
+ outputTotal?: number
+ /**
+ * Offset of the oldest retained record byte. Greater than zero exactly when
+ * retention dropped the head; a reader starting below it gets a lossy read.
+ * Present exactly when the job declared {@link JobStart.record}.
+ */
+ outputEarliest?: number
}
/** Options for {@link JobRegistry.kill}. */
@@ -159,6 +234,33 @@ export interface JobRead {
snapshot: JobSnapshot
}
+/** One retained record chunk returned by {@link JobRegistry.readRecord}. */
+export interface JobRecordChunk {
+ /** Absolute offset of the chunk's first byte. */
+ at: number
+ /** Chunk text exactly as appended (possibly tail-trimmed by retention). */
+ text: string
+ /** Stream label, when the producer supplied one. */
+ channel?: JobChannel
+ /** Producer-reported loss immediately before this chunk. */
+ gapBefore?: true
+}
+
+/** Result of one non-consuming {@link JobRegistry.readRecord}. */
+export interface JobRecordRead {
+ /** Retained chunks overlapping `[from, total)`, in offset order. */
+ chunks: readonly JobRecordChunk[]
+ /**
+ * Offset to resume from — the record's current `outputTotal`. Always a
+ * chunk boundary: appends land whole and trimming only advances chunk
+ * starts, and consumers concatenate `chunks` under that assumption, so a
+ * provider serving partial chunks would silently duplicate text.
+ */
+ next: number
+ /** True when `from` fell below the oldest retained byte, so bytes are missing before `chunks`. */
+ lossy: boolean
+}
+
/**
* Completion callback with the exact owner supplied at start, or `undefined`
* for an unowned job. Returned promises are observed but not awaited.
@@ -178,3 +280,10 @@ export type JobDoneListener = (
* set changed with it.
*/
export type JobsChangedListener = (owner: Agent | undefined) => void
+
+/**
+ * Observer of record advancement for one job: new appended output, or the
+ * settlement that ends the stream. Carries no payload — a consumer schedules a
+ * {@link JobRegistry.readRecord} from its own cursor.
+ */
+export type JobOutputListener = (id: JobId) => void
diff --git a/packages/jobs/jobs/tests/invariant.spec.ts b/packages/jobs/jobs/tests/invariant.spec.ts
index 5cbaf94b11..fa42eb6c74 100644
--- a/packages/jobs/jobs/tests/invariant.spec.ts
+++ b/packages/jobs/jobs/tests/invariant.spec.ts
@@ -62,6 +62,7 @@ describe('job-registry invariants', () => {
const owner = { id: SessionId('owner') } as Agent
expect(() => { notify({ ...BASE, id: JobId('subagent-2'), kind: 'subagent', ownerSession: owner.id }, owner) })
.not.toThrow()
+ expect(() => { notify({ ...BASE, outputTotal: 8, outputEarliest: 4 }) }).not.toThrow()
})
it.each([
@@ -76,6 +77,12 @@ describe('job-registry invariants', () => {
[{ ...BASE, finishedAt: 9 }, undefined, /no earlier than startedAt/],
[{ ...BASE, finishedAt: 20.5 }, undefined, /no earlier than startedAt/],
[{ ...BASE, ownerSession: SessionId('recorded') }, { id: SessionId('actual') } as Agent, /does not match its completion owner/],
+ [{ ...BASE, outputTotal: 8 }, undefined, /record offsets must be present together/],
+ [{ ...BASE, outputEarliest: 0 }, undefined, /record offsets must be present together/],
+ [{ ...BASE, outputTotal: 8, outputEarliest: 9 }, undefined, /0 <= outputEarliest <= outputTotal/],
+ [{ ...BASE, outputTotal: 8.5, outputEarliest: 0 }, undefined, /0 <= outputEarliest <= outputTotal/],
+ [{ ...BASE, outputTotal: 8, outputEarliest: 0.5 }, undefined, /0 <= outputEarliest <= outputTotal/],
+ [{ ...BASE, outputTotal: 8, outputEarliest: -1 }, undefined, /0 <= outputEarliest <= outputTotal/],
] as const)('rejects an incoherent registry snapshot', async (snapshot, owner, message) => {
const notify = await setup()
expect(() => { notify(snapshot, owner) }).toThrow(message)
diff --git a/packages/jobs/jobs/tests/service.spec.ts b/packages/jobs/jobs/tests/service.spec.ts
index 6c4f65c52f..ff2c282a27 100644
--- a/packages/jobs/jobs/tests/service.spec.ts
+++ b/packages/jobs/jobs/tests/service.spec.ts
@@ -1,9 +1,10 @@
-import { describe, expect, it } from 'vitest'
+import { describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
-import { JobId, JobRegistry } from '@deepseek-ai/dsh-jobs'
+import { JobId, JobRegistry, pumpJobOutput } from '@deepseek-ai/dsh-jobs'
import type {
- JobDoneListener, JobRead, JobSnapshot, JobStart, JobsChangedListener,
+ JobAppendOptions, JobDoneListener, JobOutputListener, JobPumpSource, JobRead, JobRecordRead,
+ JobSnapshot, JobStart, JobsChangedListener, RunningJob,
} from '@deepseek-ai/dsh-jobs'
/**
@@ -24,8 +25,9 @@ class StubJobRegistry extends JobRegistry {
}
start(spec: JobStart): JobId {
- spec.run()
- return JobId(`${spec.kind}-1`)
+ const id = JobId(`${spec.kind}-1`)
+ spec.run({ id, append() {}, updateDetail() {} })
+ return id
}
list(): JobSnapshot[] {
@@ -56,6 +58,14 @@ class StubJobRegistry extends JobRegistry {
return () => {}
}
+ readRecord(_id: JobId, from: number): JobRecordRead {
+ return { chunks: [], next: from, lossy: false }
+ }
+
+ onOutput(_listener: JobOutputListener): () => void {
+ return () => {}
+ }
+
attachController(_name: string): () => void {
return () => {}
}
@@ -74,10 +84,13 @@ describe('JobRegistry seam', () => {
expect(ctx.jobs.read(id).text).toBe('')
expect(ctx.jobs.kill(id)).toBe('requested')
await expect(ctx.jobs.wait(id, 5)).resolves.toMatchObject({ id })
+ expect(ctx.jobs.readRecord(id, 0)).toEqual({ chunks: [], next: 0, lossy: false })
const detachListener = ctx.jobs.onJobDone(() => {})
detachListener()
const detachChanges = ctx.jobs.onJobsChanged(() => {})
detachChanges()
+ const detachOutput = ctx.jobs.onOutput(() => {})
+ detachOutput()
detachController()
})
@@ -94,3 +107,83 @@ describe('JobRegistry seam', () => {
.rejects.toThrow(/abstract job registry seam; load an implementation such as @deepseek-ai\/dsh-jobs-local/)
})
})
+
+/** Record every append so pump behavior is observable without a registry. */
+function recordingJob(): { job: RunningJob; appends: { text: string; options?: JobAppendOptions }[] } {
+ const appends: { text: string; options?: JobAppendOptions }[] = []
+ const job: RunningJob = {
+ id: JobId('bash-1'),
+ append(text, options) { appends.push({ text, ...options !== undefined ? { options } : {} }) },
+ updateDetail() {},
+ }
+ return { job, appends }
+}
+
+/** A scripted source: each read() shifts the next scripted result. */
+function scriptedSource(
+ reads: { text: string; lossy?: boolean }[],
+ channel?: 'stdout' | 'stderr',
+): { source: JobPumpSource; offsets: number[] } {
+ const offsets: number[] = []
+ let offset = 0
+ const source: JobPumpSource = {
+ ...channel !== undefined ? { channel } : {},
+ read(fromByte) {
+ offsets.push(fromByte)
+ const next = reads.shift() ?? { text: '' }
+ offset += Buffer.byteLength(next.text, 'utf8')
+ return { text: next.text, nextOffset: offset, lossy: next.lossy ?? false }
+ },
+ }
+ return { source, offsets }
+}
+
+describe('pumpJobOutput', () => {
+ it('rejects a non-positive poll interval before touching any source', async () => {
+ const { job } = recordingJob()
+ await expect(pumpJobOutput(job, [], { pollMs: 0, done: Promise.resolve() }))
+ .rejects.toThrow(/invalid pump pollMs/)
+ })
+
+ it('copies labeled deltas at the poll cadence and resumes each source at its own offset', async () => {
+ vi.useFakeTimers()
+ try {
+ const { job, appends } = recordingJob()
+ let settle!: () => void
+ const done = new Promise((resolve) => { settle = resolve })
+ const out = scriptedSource([{ text: 'a' }, { text: 'bc' }], 'stdout')
+ const err = scriptedSource([{ text: '' }, { text: 'E' }], 'stderr')
+ const pump = pumpJobOutput(job, [out.source, err.source], { pollMs: 50, done })
+
+ await vi.advanceTimersByTimeAsync(50)
+ settle()
+ await pump
+ expect(appends).toEqual([
+ { text: 'a', options: { channel: 'stdout' } },
+ { text: 'bc', options: { channel: 'stdout' } },
+ { text: 'E', options: { channel: 'stderr' } },
+ ])
+ // Every read resumed from the previous nextOffset, never from a shared cursor.
+ expect(out.offsets).toEqual([0, 1, 3])
+ expect(err.offsets).toEqual([0, 0, 1])
+ // Settlement cleared the raced poll timer instead of leaving it pending.
+ expect(vi.getTimerCount()).toBe(0)
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ it('marks a lossy source read as a gap so observers see the discontinuity', async () => {
+ const { job, appends } = recordingJob()
+ const { source } = scriptedSource([{ text: 'tail', lossy: true }])
+ await pumpJobOutput(job, [source], { pollMs: 1, done: Promise.resolve() })
+ expect(appends).toEqual([{ text: 'tail', options: { gapBefore: true } }])
+ })
+
+ it('treats a rejected done as settlement and still drains the final bytes', async () => {
+ const { job, appends } = recordingJob()
+ const { source } = scriptedSource([{ text: 'last' }])
+ await pumpJobOutput(job, [source], { pollMs: 1, done: Promise.reject(new Error('producer broke')) })
+ expect(appends).toEqual([{ text: 'last' }])
+ })
+})
diff --git a/packages/shell/tool-bash/README.i18n.yaml b/packages/shell/tool-bash/README.i18n.yaml
index ca36cefcab..39c73313cc 100644
--- a/packages/shell/tool-bash/README.i18n.yaml
+++ b/packages/shell/tool-bash/README.i18n.yaml
@@ -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/shell/tool-bash/README.md
-README.md: 526cf62877bb094fa78c997e97cf556c3de7f888
-README.zh.md: f7ad3326976e80aa59aa8f38814ecd4a659cac36
+README.md: ece6ae505bb847e9095918c67a8da12c40b8af96
+README.zh.md: fe066880951b62b40a24aef59611bfbf23b34473
diff --git a/packages/shell/tool-bash/README.md b/packages/shell/tool-bash/README.md
index 526cf62877..ece6ae505b 100644
--- a/packages/shell/tool-bash/README.md
+++ b/packages/shell/tool-bash/README.md
@@ -56,7 +56,7 @@ The tool executes `bash -c ` and returns the combined output. Commands
### Running long commands in the background
-Passing `run_in_background: true` returns a job id immediately and no timeout applies; the command keeps running while the agent works on something else. The agent reads its output with `job_output` (non-blocking unless `wait: true`), lists jobs with `job_list`, and stops it with `job_kill`; a finished job notifies the owning agent in-session. Background support needs the generic job runtime (`dsh-jobs-local`) and its control tools (`dsh-tool-jobs`) mounted. When the optional `ctx.activities` registry is loaded, the committed background run is also mirrored into it best-effort: a `bash` activity correlated to the call and job pumps the handle's non-consuming `observed` readers at `activityPollMs` (default 150), so the Web client streams live output without touching the job's cursor; any observation failure is logged and swallowed.
+Passing `run_in_background: true` returns a job id immediately and no timeout applies; the command keeps running while the agent works on something else. The agent reads its output with `job_output` (non-blocking unless `wait: true`), lists jobs with `job_list`, and stops it with `job_kill`; a finished job notifies the owning agent in-session. Background support needs the generic job runtime (`dsh-jobs-local`) and its control tools (`dsh-tool-jobs`) mounted. The background job declares an observation record: the run's non-consuming `observed` readers are pumped into it at `recordPollMs` (default 150), so the Web client streams live output without touching the job's consuming cursor; a pump failure is logged and swallowed while the job runs on.
### Timeout promotion
diff --git a/packages/shell/tool-bash/README.zh.md b/packages/shell/tool-bash/README.zh.md
index f7ad332697..fe06688095 100644
--- a/packages/shell/tool-bash/README.zh.md
+++ b/packages/shell/tool-bash/README.zh.md
@@ -56,7 +56,7 @@ kind: "package-reference"
### 后台运行长时间命令
-传入 `run_in_background: true` 会立即返回 job id,不应用超时;命令继续运行,agent 同时处理其他事情。agent 用 `job_output` 读取输出(除非 `wait: true`,否则非阻塞)、用 `job_list` 列出任务、用 `job_kill` 停止任务;完成的任务会在会话内通知拥有它的 agent。后台支持需要挂载通用任务运行时(`dsh-jobs-local`)及其控制工具(`dsh-tool-jobs`)。加载了可选的 `ctx.activities` 注册表时,已提交的后台运行还会被尽力镜像进去:一个与调用和 job 关联的 `bash` activity 以 `activityPollMs`(默认 150)的节奏泵送句柄的非消费 `observed` 读取器,使 Web 客户端在不触碰 job 游标的情况下流式看到实时输出;任何观察失败都被记录并吞掉。
+传入 `run_in_background: true` 会立即返回 job id,不应用超时;命令继续运行,agent 同时处理其他事情。agent 用 `job_output` 读取输出(除非 `wait: true`,否则非阻塞)、用 `job_list` 列出任务、用 `job_kill` 停止任务;完成的任务会在会话内通知拥有它的 agent。后台支持需要挂载通用任务运行时(`dsh-jobs-local`)及其控制工具(`dsh-tool-jobs`)。后台 job 声明观测 record:运行的非消费 `observed` 读取器以 `recordPollMs`(默认 150)的节奏泵入其中,使 Web 客户端在不触碰 job 消耗游标的情况下流式看到实时输出;泵失败会被记录并吞掉,job 继续运行。
### 超时转后台
diff --git a/packages/shell/tool-bash/package.json b/packages/shell/tool-bash/package.json
index 7c143f415d..d4be992ae9 100644
--- a/packages/shell/tool-bash/package.json
+++ b/packages/shell/tool-bash/package.json
@@ -37,7 +37,6 @@
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-jobs": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
- "@deepseek-ai/dsh-activity": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"@deepseek-ai/dsh-shell": "workspace:^",
@@ -59,7 +58,6 @@
"@deepseek-ai/dsh-jobs": "workspace:^",
"@deepseek-ai/dsh-jobs-local": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
- "@deepseek-ai/dsh-activity": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
diff --git a/packages/shell/tool-bash/src/index.ts b/packages/shell/tool-bash/src/index.ts
index a9c84a3fc1..9df34c1809 100644
--- a/packages/shell/tool-bash/src/index.ts
+++ b/packages/shell/tool-bash/src/index.ts
@@ -22,10 +22,9 @@ import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandb
import { ESCALATION_TARGETS, approveEscalation, canonicalPath, validateEscalationArgs } from '@deepseek-ai/dsh-sandbox'
import type { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-shell'
-import type { ShellProcess, ShellRunResult } from '@deepseek-ai/dsh-shell'
-import type {} from '@deepseek-ai/dsh-activity'
+import type { ShellRunResult } from '@deepseek-ai/dsh-shell'
import { processOutcome } from './background.ts'
-import { observeBackgroundActivity } from './observe.ts'
+import { observeProcessRecord } from './observe.ts'
import { parseExitStatus, renderProcessRead, renderPromoted, renderResult } from './render.ts'
export const name = 'tool-bash'
@@ -35,8 +34,8 @@ export const inject = ['tools', 'shell', 'systemPrompt', 'shellEnv']
export interface Config {
/** Expose `run_in_background` (default true); disabled calls are also rejected. */
enableRunInBackground?: boolean
- /** Poll cadence for mirroring background output into `ctx.activities`, in milliseconds (default 150). */
- activityPollMs?: number
+ /** Poll cadence for copying background output into the job record, in milliseconds (default 150). */
+ recordPollMs?: number
/**
* Move a foreground command that reaches its timeout into the background as
* a job instead of killing it (default true). Requires background execution:
@@ -49,7 +48,7 @@ export interface Config {
/** Runtime configuration schema for the bash tool plugin. */
export const Config: z = z.object({
enableRunInBackground: z.boolean().default(true),
- activityPollMs: z.number()
+ recordPollMs: z.number()
.step(1)
.min(1)
.max(Number.MAX_SAFE_INTEGER)
@@ -213,7 +212,7 @@ const BACKGROUND_OUTPUT_PROPERTIES = {
export function apply(ctx: Context, config: Config = {}): void {
const backgroundEnabled = config.enableRunInBackground ?? true
- const activityPollMs = config.activityPollMs ?? 150
+ const recordPollMs = config.recordPollMs ?? 150
// Promotion needs the whole background surface: the job tools to collect and
// stop the promoted work, and the registry itself at execution time.
const promoteOnTimeout = (config.promoteOnTimeout ?? true) && backgroundEnabled
@@ -406,32 +405,23 @@ export function apply(ctx: Context, config: Config = {}): void {
error.name = 'AbortError'
throw error
}
- // Task preflight finishes before the starter can spawn a process.
- let started: ShellProcess | undefined
const id = jobs.start({
kind: 'bash',
label: args.command,
...exec.agent ? { owner: exec.agent } : {},
- run: () => {
+ record: true,
+ run: (job) => {
const proc = ctx.shell.execute(ctx.shell.resolve({ ...request, onExpiry: 'none' }))
- started = proc
+ // The pump's final drain is folded into `done` so the record holds
+ // its last bytes before settlement trims and closes it.
+ const observed = observeProcessRecord(ctx, job, proc, recordPollMs)
return {
cancel: () => void proc.kill(),
- done: proc.done.then(() => processOutcome(proc)),
+ done: observed.then(() => proc.done).then(() => processOutcome(proc)),
readOutput: () => renderProcessRead(proc.readOutput(), proc.sandbox, escalationModes),
}
},
})
- // The starter ran synchronously inside the committed start; mirror the
- // spawned process into the optional observation registry.
- if (started !== undefined) {
- observeBackgroundActivity(ctx, started, {
- kind: 'bash',
- label: args.command,
- ...exec.agent ? { owner: exec.agent } : {},
- correlation: { callId: exec.callId, jobId: id },
- }, activityPollMs, processOutcome)
- }
return { kind: 'background' as const, jobId: id }
}
// Promotion needs a live registry at execution time; without one the
@@ -454,11 +444,17 @@ export function apply(ctx: Context, config: Config = {}): void {
kind: 'bash',
label: args.command,
...exec.agent ? { owner: exec.agent } : {},
- run: () => ({
- cancel: () => void foreground.kill(),
- done: foreground.done.then(() => processOutcome(foreground)),
- readOutput: () => renderProcessRead(foreground.readOutput(), foreground.sandbox, escalationModes),
- }),
+ record: true,
+ run: (job) => {
+ // The pump's final drain is folded into `done` so the record
+ // holds its last bytes before settlement trims and closes it.
+ const observed = observeProcessRecord(ctx, job, foreground, recordPollMs)
+ return {
+ cancel: () => void foreground.kill(),
+ done: observed.then(() => foreground.done).then(() => processOutcome(foreground)),
+ readOutput: () => renderProcessRead(foreground.readOutput(), foreground.sandbox, escalationModes),
+ }
+ },
})
} catch (error) {
// Admission or controller preflight refused the promotion: fall
@@ -467,12 +463,6 @@ export function apply(ctx: Context, config: Config = {}): void {
}
if (promoted !== undefined) {
offer.accept()
- observeBackgroundActivity(ctx, foreground, {
- kind: 'bash',
- label: args.command,
- ...exec.agent ? { owner: exec.agent } : {},
- correlation: { callId: exec.callId, jobId: promoted },
- }, activityPollMs, processOutcome)
// One consuming read seeds the result with the output so far; the
// job's cursor continues exactly after it, no repeat and no gap.
return {
diff --git a/packages/shell/tool-bash/src/observe.ts b/packages/shell/tool-bash/src/observe.ts
index 3dc2aeeb79..2e2874f9c1 100644
--- a/packages/shell/tool-bash/src/observe.ts
+++ b/packages/shell/tool-bash/src/observe.ts
@@ -1,82 +1,44 @@
/**
- * Best-effort process-observation tap for background bash runs: mirrors the
- * spawned process's captured streams into `ctx.activities` so non-consuming
- * observers (the Web client) see live output without touching the job's
- * consuming `readOutput` cursor. Absent registry or absent backend offset
- * readers degrade to no observation; the job path is unaffected either way.
+ * Record tap for background bash runs: copies the spawned process's captured
+ * streams into the job's observation record, so non-consuming observers (the
+ * Web client) see live output without touching the job's consuming
+ * `readOutput` cursor. Observation is strictly best-effort: absent backend
+ * offset readers degrade to no observation, and a pump failure is logged and
+ * swallowed — the job path is unaffected either way.
*
* @module @deepseek-ai/dsh-tool-bash/observe
*/
import type { Context } from '@deepseek-ai/cordis'
-import type { Agent } from '@deepseek-ai/dsh-agent'
import type { ShellProcess } from '@deepseek-ai/dsh-shell'
-import { pumpActivityOutput } from '@deepseek-ai/dsh-activity'
-import type { ActivityCorrelation, ActivityKind, ActivityPumpSource } from '@deepseek-ai/dsh-activity'
-
-/** Identity of one observed background run. */
-export interface ObserveBackgroundSpec {
- /** Process kind — the producing tool's registered {@link ActivityKind}. */
- kind: ActivityKind
- /** One-line label (the command). */
- label: string
- /** Owning live agent, when the call has one. */
- owner?: Agent
- /** Links to the starting tool call and the registered job. */
- correlation: ActivityCorrelation
-}
+import { pumpJobOutput } from '@deepseek-ai/dsh-jobs'
+import type { JobPumpSource, RunningJob } from '@deepseek-ai/dsh-jobs'
/**
- * Open a process beside a committed background job and pump the handle's
- * non-consuming offset readers into it until the run settles, then record the
- * mapped outcome. Observation is strictly best-effort: without a
- * `ctx.activities` registry this is a no-op, without backend offset readers
- * the process still carries status and settlement, and ANY observation
- * failure — including a rejected `open()` — is logged and swallowed, because
- * the background job is already committed and running.
- * @param ctx - plugin context used for the optional registry and warning logs.
+ * Pump the process's non-consuming offset readers into the job record until
+ * the run settles, then drain once more. The returned promise never rejects:
+ * a pump failure is logged and the record simply stops advancing, while the
+ * process and its job continue untouched.
+ * @param ctx - plugin context used for the warning log.
+ * @param job - the running job's producer face receiving the copied chunks.
* @param proc - the started background process handle.
- * @param spec - process identity and correlation for the registry row.
* @param pollMs - producer-configured pump cadence in milliseconds.
- * @param outcome - maps the settled handle to the process outcome.
+ * @returns resolves after the final drain; fold into the job's `done` chain so
+ * the record holds its final bytes before settlement closes it.
*/
-export function observeBackgroundActivity(
+export function observeProcessRecord(
ctx: Context,
+ job: RunningJob,
proc: ShellProcess,
- spec: ObserveBackgroundSpec,
pollMs: number,
- outcome: (proc: ShellProcess) => { status: 'completed' | 'killed'; detail: string },
-): void {
- try {
- const processes = ctx.get('activities')
- if (processes === undefined) return
- const handle = processes.open({
- kind: spec.kind,
- label: spec.label,
- ...spec.owner !== undefined ? { owner: spec.owner } : {},
- correlation: spec.correlation,
+): Promise {
+ const sources: JobPumpSource[] = []
+ const stdout = proc.observed?.stdout
+ if (stdout !== undefined) sources.push({ channel: 'stdout', read: from => stdout.readFrom(from) })
+ const stderr = proc.observed?.stderr
+ if (stderr !== undefined) sources.push({ channel: 'stderr', read: from => stderr.readFrom(from) })
+ return pumpJobOutput(job, sources, { pollMs, done: proc.done })
+ .catch((error: unknown) => {
+ ctx.logger.warn(`record observation pump for ${job.id} failed: ${String(error)}`)
})
- const sources: ActivityPumpSource[] = []
- const stdout = proc.observed?.stdout
- if (stdout !== undefined) sources.push({ channel: 'stdout', read: from => stdout.readFrom(from) })
- const stderr = proc.observed?.stderr
- if (stderr !== undefined) sources.push({ channel: 'stderr', read: from => stderr.readFrom(from) })
- void pumpActivityOutput(handle, sources, { pollMs, done: proc.done })
- .catch((error: unknown) => {
- ctx.logger.warn(`activity observation pump for ${handle.id} failed: ${String(error)}`)
- })
- .then(async () => {
- // The pump can fail while the process is still running; the outcome
- // mapping requires a settled handle, and `end` is first-wins, so an
- // early mapping would freeze a wrong terminal state onto the row.
- // `proc.done` never rejects.
- await proc.done
- handle.end(outcome(proc))
- })
- .catch((error: unknown) => {
- ctx.logger.warn(`activity settlement mapping for ${handle.id} failed: ${String(error)}`)
- })
- } catch (error: unknown) {
- ctx.logger.warn(`activity observation unavailable for this run: ${String(error)}`)
- }
}
diff --git a/packages/shell/tool-bash/tests/observe.spec.ts b/packages/shell/tool-bash/tests/observe.spec.ts
index 72d951c60a..90150a5dfb 100644
--- a/packages/shell/tool-bash/tests/observe.spec.ts
+++ b/packages/shell/tool-bash/tests/observe.spec.ts
@@ -3,26 +3,27 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
-import type { Agent } from '@deepseek-ai/dsh-agent'
import { ToolCallId } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRuntime from '@deepseek-ai/dsh-tools'
import AgentRegistry from '@deepseek-ai/dsh-agent'
+import type { Agent } from '@deepseek-ai/dsh-agent'
import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local'
+import type { JobId, RunningJob } from '@deepseek-ai/dsh-jobs'
import * as ToolTasks from '@deepseek-ai/dsh-tool-jobs'
-import LocalActivityRegistry from '@deepseek-ai/dsh-activity-local'
-import type { ActivitySnapshot } from '@deepseek-ai/dsh-activity'
+import type { ShellProcess } from '@deepseek-ai/dsh-shell'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
+import { observeProcessRecord } from '../src/observe.ts'
import { renderPromoted } from '../src/render.ts'
import * as BashEnvPlugin from '@deepseek-ai/dsh-shell-env'
const testToolSignal = new AbortController().signal
const spillDir = mkdtempSync(join(tmpdir(), 'dsh-tool-bash-observe-spec-'))
-/** Job harness plus the observation registry, with a fast pump for tests. */
+/** Job harness with a fast record pump for tests. */
async function setup() {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
@@ -30,12 +31,11 @@ async function setup() {
await ctx.plugin(AgentRegistry)
await ctx.plugin(LocalJobRegistry)
await ctx.plugin(ToolTasks)
- await ctx.plugin(LocalActivityRegistry)
await ctx.plugin(LocalSubprocessRuntime)
;(ctx.subprocess as LocalSubprocessRuntime).internals = { spillDir }
await ctx.plugin(BashEnvPlugin)
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 })
- await ctx.plugin(ToolBash, { activityPollMs: 25 })
+ await ctx.plugin(ToolBash, { recordPollMs: 25 })
return ctx
}
@@ -63,12 +63,12 @@ async function until(read: () => T | undefined, timeoutMs = 5_000): Promise chunk.text).join('')
+function retainedText(ctx: Context, id: JobId, caller?: Agent): string {
+ return ctx.jobs.readRecord(id, 0, caller).chunks.map(chunk => chunk.text).join('')
}
-describe('background bash observation', () => {
- it('mirrors a background run into ctx.activities with correlation, live output, and settlement', async () => {
+describe('background bash record', () => {
+ it('streams a background run into the job record with live output and settlement', async () => {
const ctx = await setup()
const ack = await call(ctx, {
command: 'printf "line-1\\n"; sleep 0.4; printf "line-2\\n"',
@@ -78,21 +78,16 @@ describe('background bash observation', () => {
expect(text(ack)).toContain('started background job')
const job = ctx.jobs.list()[0]
expect(job).toBeDefined()
+ expect(job!.outputTotal).toBeDefined()
- const snapshot = await until(() =>
- ctx.activities.list().find(row => row.correlation?.jobId === job!.id))
- expect(snapshot.kind).toBe('bash')
- expect(snapshot.label).toContain('printf')
- expect(String(snapshot.correlation?.callId)).toMatch(/^observe-call-/)
+ // Live output appears in the record while the command is still running.
+ await until(() => retainedText(ctx, job!.id).includes('line-1') ? true : undefined)
+ expect(ctx.jobs.get(job!.id).status).toBe('running')
- // Live output appears while the command is still running.
- await until(() => retainedText(ctx, snapshot.id).includes('line-1') ? true : undefined)
- expect(ctx.activities.get(snapshot.id).status).toBe('running')
-
- // Settlement carries the mapped bash outcome and the trailing output.
- await until(() => ctx.activities.get(snapshot.id).status === 'completed' ? true : undefined)
- expect(ctx.activities.get(snapshot.id).detail).toBe('exit code: 0')
- expect(retainedText(ctx, snapshot.id)).toContain('line-2')
+ // Settlement ends the record with the job; the trailing bytes are drained first.
+ await until(() => ctx.jobs.get(job!.id).status === 'completed' ? true : undefined)
+ expect(ctx.jobs.get(job!.id).detail).toBe('exit code: 0')
+ expect(retainedText(ctx, job!.id)).toContain('line-2')
// The model-facing consuming cursor still delivers everything: observation stole nothing.
const collected = await until(() => {
@@ -102,15 +97,13 @@ describe('background bash observation', () => {
expect(collected).toContain('line-2')
})
- it('a killed background job settles its mirrored process as killed', async () => {
+ it('a killed background job ends its record with the killed settlement', async () => {
const ctx = await setup()
await call(ctx, { command: 'sleep 60', description: 'test command', run_in_background: true })
const job = ctx.jobs.list()[0]
- const snapshot = await until(() =>
- ctx.activities.list().find(row => row.correlation?.jobId === job!.id))
ctx.jobs.kill(job!.id, undefined, { reason: 'test cleanup' })
- await until(() => ctx.activities.get(snapshot.id).status === 'killed' ? true : undefined)
- expect(ctx.activities.get(snapshot.id).detail).toMatch(/signal|killed before exit/)
+ await until(() => ctx.jobs.get(job!.id).status === 'killed' ? true : undefined)
+ expect(ctx.jobs.get(job!.id).detail).toMatch(/signal|killed before exit/)
})
it('labels stderr chunks with their channel', async () => {
@@ -121,153 +114,79 @@ describe('background bash observation', () => {
run_in_background: true,
})
const job = ctx.jobs.list()[0]
- const snapshot = await until(() =>
- ctx.activities.list().find(row => row.correlation?.jobId === job!.id))
- await until(() => ctx.activities.get(snapshot.id).status === 'completed' ? true : undefined)
- const chunks = ctx.activities.read(snapshot.id, 0).chunks
+ await until(() => ctx.jobs.get(job!.id).status === 'completed' ? true : undefined)
+ const chunks = ctx.jobs.readRecord(job!.id, 0).chunks
expect(chunks.find(chunk => chunk.text.includes('out-line'))?.channel).toBe('stdout')
expect(chunks.find(chunk => chunk.text.includes('err-line'))?.channel).toBe('stderr')
})
+})
- it('a registry whose handle rejects appends degrades to a warning and still settles', async () => {
- const ctx = new Context()
- await ctx.plugin(SystemPrompt)
- await ctx.plugin(ToolRuntime)
- await ctx.plugin(AgentRegistry)
- await ctx.plugin(LocalJobRegistry)
- await ctx.plugin(ToolTasks)
- const ended: unknown[] = []
- await ctx.plugin({
- name: 'appending-broken-activities-probe',
- apply(child: Context) {
- child.provide('activities', {
- open: () => ({
- id: 'bash-1',
- append() { throw new Error('append boom') },
- updateDetail() {},
- end(outcome: unknown) { ended.push(outcome) },
- }),
- })
+describe('observeProcessRecord', () => {
+ /** A RunningJob face recording appends for pump assertions. */
+ function recordingJob(): { job: RunningJob; appends: string[] } {
+ const appends: string[] = []
+ return {
+ job: {
+ id: 'bash-1' as JobId,
+ append(chunk) { appends.push(chunk) },
+ updateDetail() {},
},
- })
- await ctx.plugin(LocalSubprocessRuntime)
- ;(ctx.subprocess as LocalSubprocessRuntime).internals = { spillDir }
- await ctx.plugin(BashEnvPlugin)
- await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 })
- const warn = vi.fn()
- ctx.logger.warn = warn as never
- await ctx.plugin(ToolBash, { activityPollMs: 25 })
- await call(ctx, { command: 'echo tolerated', description: 'test command', run_in_background: true })
- await until(() => ended.length > 0 ? true : undefined)
- expect(warn).toHaveBeenCalledWith(expect.stringContaining('activity observation pump'))
- expect(ended[0]).toMatchObject({ status: 'completed' })
+ appends,
+ }
+ }
+
+ it('degrades to no observation when the backend exposes no offset readers', async () => {
+ const ctx = new Context()
+ const { job, appends } = recordingJob()
+ const proc = { done: Promise.resolve() } as unknown as ShellProcess
+ await observeProcessRecord(ctx, job, proc, 25)
+ expect(appends).toEqual([])
})
- it('a pump failure waits for process settlement before mapping the outcome', async () => {
+ it('contains a pump failure with a warning; the job path is unaffected', async () => {
const ctx = new Context()
- await ctx.plugin(SystemPrompt)
- await ctx.plugin(ToolRuntime)
- await ctx.plugin(AgentRegistry)
- await ctx.plugin(LocalJobRegistry)
- await ctx.plugin(ToolTasks)
- const ended: unknown[] = []
- await ctx.plugin({
- name: 'early-failing-activities-probe',
- apply(child: Context) {
- child.provide('activities', {
- open: () => ({
- id: 'bash-1',
- append() { throw new Error('append boom') },
- updateDetail() {},
- end(outcome: unknown) { ended.push(outcome) },
- }),
- })
- },
- })
- await ctx.plugin(LocalSubprocessRuntime)
- ;(ctx.subprocess as LocalSubprocessRuntime).internals = { spillDir }
- await ctx.plugin(BashEnvPlugin)
- await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 })
const warn = vi.fn()
ctx.logger.warn = warn as never
- await ctx.plugin(ToolBash, { activityPollMs: 25 })
- // One early line makes the first pump poll append (and fail) while the
- // process still has most of its sleep ahead.
- await call(ctx, { command: 'printf "early\\n"; sleep 0.7', description: 'test command', run_in_background: true })
- await until(() => warn.mock.calls.some(args => String(args[0]).includes('activity observation pump')) ? true : undefined)
- // The pump already failed; the outcome must wait for real settlement
- // rather than freezing a fake terminal state onto the running process.
- expect(ended).toHaveLength(0)
- await until(() => ended.length > 0 ? true : undefined)
- expect(ended[0]).toMatchObject({ status: 'completed', detail: 'exit code: 0' })
+ const { job } = recordingJob()
+ const proc = {
+ done: new Promise((resolve) => { setTimeout(resolve, 10) }),
+ observed: {
+ stdout: { readFrom() { throw new Error('reader boom') } },
+ },
+ } as unknown as ShellProcess
+ await observeProcessRecord(ctx, job, proc, 5)
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('record observation pump for bash-1 failed'))
})
+})
- it('a throwing end() is swallowed by the settlement-mapping catch', async () => {
- const ctx = new Context()
- await ctx.plugin(SystemPrompt)
- await ctx.plugin(ToolRuntime)
- await ctx.plugin(AgentRegistry)
- await ctx.plugin(LocalJobRegistry)
- await ctx.plugin(ToolTasks)
- await ctx.plugin({
- name: 'end-throwing-activities-probe',
- apply(child: Context) {
- child.provide('activities', {
- open: () => ({
- id: 'bash-1',
- append() {},
- updateDetail() {},
- end() { throw new Error('end boom') },
- }),
- })
- },
+describe('owned background record', () => {
+ it('fences an owned background run record under the owning session', async () => {
+ const ctx = await setup()
+ const owner = {
+ id: SessionId('observe-owner'),
+ session: { id: SessionId('observe-owner'), header: { cwd: process.cwd() } },
+ status: 'idle',
+ ctx,
+ } as unknown as Agent
+ ctx.agents.register(owner)
+ await ctx.tools.execute({
+ signal: testToolSignal,
+ callId: ToolCallId('observe-owned-1'),
+ name: 'bash',
+ arguments: { command: 'echo owned', description: 'test command', run_in_background: true },
+ agent: owner,
})
- await ctx.plugin(LocalSubprocessRuntime)
- ;(ctx.subprocess as LocalSubprocessRuntime).internals = { spillDir }
- await ctx.plugin(BashEnvPlugin)
- await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 })
- const warn = vi.fn()
- ctx.logger.warn = warn as never
- await ctx.plugin(ToolBash, { activityPollMs: 25 })
- await call(ctx, { command: 'echo settled', description: 'test command', run_in_background: true })
- await until(() => warn.mock.calls.some(args => String(args[0]).includes('activity settlement mapping')) ? true : undefined)
- })
-
- it('a throwing observation registry never breaks the background call', async () => {
- const ctx = new Context()
- await ctx.plugin(SystemPrompt)
- await ctx.plugin(ToolRuntime)
- await ctx.plugin(AgentRegistry)
- await ctx.plugin(LocalJobRegistry)
- await ctx.plugin(ToolTasks)
- await ctx.plugin({
- name: 'broken-activities-probe',
- apply(child: Context) {
- child.provide('activities', { open() { throw new Error('observation boom') } })
- },
- })
- await ctx.plugin(LocalSubprocessRuntime)
- ;(ctx.subprocess as LocalSubprocessRuntime).internals = { spillDir }
- await ctx.plugin(BashEnvPlugin)
- await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 })
- const warn = vi.fn()
- ctx.logger.warn = warn as never
- await ctx.plugin(ToolBash, { activityPollMs: 25 })
- const ack = await call(ctx, { command: 'echo resilient', description: 'test command', run_in_background: true })
- expect(text(ack)).toContain('started background job')
- expect(warn).toHaveBeenCalledWith(expect.stringContaining('activity observation unavailable'))
- // The job itself still completes and reads normally.
- const job = ctx.jobs.list()[0]
- const read = await until(() => {
- const value = ctx.jobs.read(job!.id)
- return value.text.includes('resilient') ? value : undefined
- })
- expect(read.snapshot.status).toBe('completed')
+ const job = ctx.jobs.list(owner)[0]
+ expect(job).toBeDefined()
+ expect(job!.ownerSession).toBe(owner.id)
+ await until(() => ctx.jobs.get(job!.id, owner).status === 'completed' ? true : undefined)
+ expect(() => retainedText(ctx, job!.id)).toThrow(/belongs to another session/)
+ await until(() => retainedText(ctx, job!.id, owner).includes('owned') ? true : undefined)
})
})
describe('foreground timeout promotion', () => {
- it('moves a timed-out foreground command into a job with its output so far, mirrored as an activity', async () => {
+ it('moves a timed-out foreground command into a record job with its output so far', async () => {
const ctx = await setup()
const result = await call(ctx, {
command: 'printf "early-output\\n"; sleep 30',
@@ -285,10 +204,11 @@ describe('foreground timeout promotion', () => {
const job = ctx.jobs.list()[0]
expect(job).toMatchObject({ id: 'bash-1', kind: 'bash', status: 'running', label: 'printf "early-output\\n"; sleep 30' })
- // The activity mirror carries the tool-call correlation like any background run.
- const snapshot = await until(() =>
- ctx.activities.list().find(row => row.correlation?.jobId === job!.id))
- expect(snapshot.kind).toBe('bash')
+ // The promoted job declares a record like any background run, and the
+ // record keeps the whole stream — including the pre-promotion line the
+ // consuming cursor already delivered.
+ expect(job!.outputTotal).toBeDefined()
+ await until(() => retainedText(ctx, job!.id).includes('early-output') ? true : undefined)
// The job's consuming cursor continues after the promoted result's output:
// no repeat of the early line, and the job stays killable.
@@ -368,29 +288,3 @@ describe('renderPromoted', () => {
.toContain('line\n[still running after 250ms')
})
})
-
-describe('owned background observation', () => {
- it('mirrors an owned background run under the owning session', async () => {
- const ctx = await setup()
- const owner = {
- id: SessionId('observe-owner'),
- session: { id: SessionId('observe-owner'), header: { cwd: process.cwd() } },
- status: 'idle',
- ctx,
- } as unknown as Agent
- ctx.agents.register(owner)
- await ctx.tools.execute({
- signal: testToolSignal,
- callId: ToolCallId('observe-owned-1'),
- name: 'bash',
- arguments: { command: 'sleep 0.3', description: 'test command', run_in_background: true },
- agent: owner,
- })
- const job = ctx.jobs.list(owner)[0]
- expect(job).toBeDefined()
- const row = await until(() => ctx.activities.list(owner).find(item => item.correlation?.jobId === job!.id))
- expect(row.ownerSession).toBe(owner.id)
- ctx.jobs.kill(job!.id, owner, { reason: 'test cleanup' })
- await until(() => ctx.jobs.get(job!.id, owner).status === 'killed' ? true : undefined)
- })
-})
diff --git a/packages/shell/tool-bash/tsconfig.json b/packages/shell/tool-bash/tsconfig.json
index 8aa66f93ff..304d983a95 100644
--- a/packages/shell/tool-bash/tsconfig.json
+++ b/packages/shell/tool-bash/tsconfig.json
@@ -49,9 +49,6 @@
},
{
"path": "../../sandbox/sandbox-policy"
- },
- {
- "path": "../../activity/activity"
}
]
}
diff --git a/packages/shell/tool-pwsh/README.i18n.yaml b/packages/shell/tool-pwsh/README.i18n.yaml
index da48be6ec2..da21509836 100644
--- a/packages/shell/tool-pwsh/README.i18n.yaml
+++ b/packages/shell/tool-pwsh/README.i18n.yaml
@@ -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/shell/tool-pwsh/README.md
-README.md: 1b3877e07708e35a6cf5848e335c772403ab9f4e
-README.zh.md: 976de28e33ff303520a9d2779b4d2c08598e6b2a
+README.md: 44a1c4f566bcc50b4ca06785723676894fd104fe
+README.zh.md: 68c179d7695c84c134e5844a36a469898a8b2401
diff --git a/packages/shell/tool-pwsh/README.md b/packages/shell/tool-pwsh/README.md
index 1b3877e077..44a1c4f566 100644
--- a/packages/shell/tool-pwsh/README.md
+++ b/packages/shell/tool-pwsh/README.md
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
## Summary
-`dsh-tool-pwsh` gives the agent a `pwsh` tool that runs PowerShell commands through the mounted shell executor — the Windows counterpart of `dsh-tool-bash`, mirroring it call-for-call. Each call runs in a fresh pwsh process, so no state survives; `run_in_background` turns long-running commands into background jobs. Commands are PowerShell-dialect: native `C:\...` paths and `$env:NAME` variables, with no dialect translation. Every call runs with the managed `DSH_*` environment, and under a sandboxing executor the tool teaches and enforces the Windows-specific language-mode and named-pipe contracts. Mount it with a PowerShell executor such as `dsh-pwsh-local` and the `dsh-shell-env` plugin. When the optional `ctx.activities` registry is loaded, a committed background run is also mirrored into it best-effort as a `pwsh` activity pumped at `activityPollMs` (default 150), exactly like `dsh-tool-bash`.
+`dsh-tool-pwsh` gives the agent a `pwsh` tool that runs PowerShell commands through the mounted shell executor — the Windows counterpart of `dsh-tool-bash`, mirroring it call-for-call. Each call runs in a fresh pwsh process, so no state survives; `run_in_background` turns long-running commands into background jobs. Commands are PowerShell-dialect: native `C:\...` paths and `$env:NAME` variables, with no dialect translation. Every call runs with the managed `DSH_*` environment, and under a sandboxing executor the tool teaches and enforces the Windows-specific language-mode and named-pipe contracts. Mount it with a PowerShell executor such as `dsh-pwsh-local` and the `dsh-shell-env` plugin. A background run's job declares an observation record pumped at `recordPollMs` (default 150), exactly like `dsh-tool-bash`.
## Table of Contents
diff --git a/packages/shell/tool-pwsh/README.zh.md b/packages/shell/tool-pwsh/README.zh.md
index 976de28e33..68c179d769 100644
--- a/packages/shell/tool-pwsh/README.zh.md
+++ b/packages/shell/tool-pwsh/README.zh.md
@@ -9,7 +9,7 @@ kind: "package-reference"
## 概述
-`dsh-tool-pwsh` 为 agent 提供 `pwsh` 工具,通过已挂载的 shell 执行器运行 PowerShell 命令——它是 `dsh-tool-bash` 的 Windows 对应物,逐调用镜像。每次调用都运行在全新 pwsh 进程中,因此状态不会保留;`run_in_background` 把长时间运行的命令变成后台任务。命令是 PowerShell 方言:原生 `C:\...` 路径与 `$env:NAME` 变量,不做方言翻译。每次调用都运行在受管 `DSH_*` 环境中;在沙箱执行器下,工具会教授并执行 Windows 特有的语言模式与命名管道约定。请与 `dsh-pwsh-local` 等 PowerShell 执行器以及 `dsh-shell-env` 插件一起挂载。加载了可选的 `ctx.activities` 注册表时,已提交的后台运行还会被尽力镜像为按 `activityPollMs`(默认 150)泵送的 `pwsh` activity,与 `dsh-tool-bash` 完全一致。
+`dsh-tool-pwsh` 为 agent 提供 `pwsh` 工具,通过已挂载的 shell 执行器运行 PowerShell 命令——它是 `dsh-tool-bash` 的 Windows 对应物,逐调用镜像。每次调用都运行在全新 pwsh 进程中,因此状态不会保留;`run_in_background` 把长时间运行的命令变成后台任务。命令是 PowerShell 方言:原生 `C:\...` 路径与 `$env:NAME` 变量,不做方言翻译。每次调用都运行在受管 `DSH_*` 环境中;在沙箱执行器下,工具会教授并执行 Windows 特有的语言模式与命名管道约定。请与 `dsh-pwsh-local` 等 PowerShell 执行器以及 `dsh-shell-env` 插件一起挂载。后台运行的 job 声明按 `recordPollMs`(默认 150)泵送的观测 record,与 `dsh-tool-bash` 完全一致。
## 目录
diff --git a/packages/shell/tool-pwsh/package.json b/packages/shell/tool-pwsh/package.json
index b95e102218..49c3942a30 100644
--- a/packages/shell/tool-pwsh/package.json
+++ b/packages/shell/tool-pwsh/package.json
@@ -37,7 +37,6 @@
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-jobs": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
- "@deepseek-ai/dsh-activity": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"@deepseek-ai/dsh-shell": "workspace:^",
@@ -59,7 +58,6 @@
"@deepseek-ai/dsh-jobs-local": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-loader-smoke": "workspace:^",
- "@deepseek-ai/dsh-activity": "workspace:^",
"@deepseek-ai/dsh-pwsh-local": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
diff --git a/packages/shell/tool-pwsh/src/index.ts b/packages/shell/tool-pwsh/src/index.ts
index 6e6af23d7e..3f261c0c98 100644
--- a/packages/shell/tool-pwsh/src/index.ts
+++ b/packages/shell/tool-pwsh/src/index.ts
@@ -32,11 +32,10 @@ import type {} from '@deepseek-ai/dsh-user-approval'
import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox'
import { ESCALATION_TARGETS, approveEscalation, validateEscalationArgs } from '@deepseek-ai/dsh-sandbox'
import type { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
-import type { ShellProcess, ShellRunResult } from '@deepseek-ai/dsh-shell'
+import type { ShellRunResult } from '@deepseek-ai/dsh-shell'
import { parseExitStatus } from '@deepseek-ai/dsh-shell'
-import type {} from '@deepseek-ai/dsh-activity'
import { processOutcome } from './background.ts'
-import { observeBackgroundActivity } from './observe.ts'
+import { observeProcessRecord } from './observe.ts'
import { renderPwshProcessRead, renderPwshPromoted, renderPwshResult } from './render.ts'
import type { RenderablePwshResult } from './render.ts'
@@ -46,12 +45,6 @@ declare module '@deepseek-ai/dsh-jobs' {
}
}
-declare module '@deepseek-ai/dsh-activity' {
- interface ActivityKindMap {
- pwsh: 'pwsh'
- }
-}
-
export const name = 'tool-pwsh'
export const inject = ['tools', 'shell', 'systemPrompt', 'shellEnv']
@@ -60,8 +53,8 @@ export const inject = ['tools', 'shell', 'systemPrompt', 'shellEnv']
export interface Config {
/** Expose `run_in_background` (default true); disabled calls are also rejected. */
enableRunInBackground?: boolean
- /** Poll cadence for mirroring background output into `ctx.activities`, in milliseconds (default 150). */
- activityPollMs?: number
+ /** Poll cadence for copying background output into the job record, in milliseconds (default 150). */
+ recordPollMs?: number
/**
* Move a foreground command that reaches its timeout into the background as
* a job instead of killing it (default true). Requires background execution:
@@ -74,7 +67,7 @@ export interface Config {
/** Runtime configuration schema for the pwsh tool plugin. */
export const Config: z = z.object({
enableRunInBackground: z.boolean().default(true),
- activityPollMs: z.number()
+ recordPollMs: z.number()
.step(1)
.min(1)
.max(Number.MAX_SAFE_INTEGER)
@@ -225,7 +218,7 @@ const BACKGROUND_OUTPUT_PROPERTIES = {
/* jscpd:ignore-start -- deliberate mirror of dsh-tool-bash's apply() preamble (pwsh-tool-and-executor Agent Note). */
export function apply(ctx: Context, config: Config = {}): void {
const backgroundEnabled = config.enableRunInBackground ?? true
- const activityPollMs = config.activityPollMs ?? 150
+ const recordPollMs = config.recordPollMs ?? 150
// Promotion needs the whole background surface: the job tools to collect and
// stop the promoted work, and the registry itself at execution time.
const promoteOnTimeout = (config.promoteOnTimeout ?? true) && backgroundEnabled
@@ -429,32 +422,23 @@ export function apply(ctx: Context, config: Config = {}): void {
error.name = 'AbortError'
throw error
}
- // Task preflight finishes before the starter can spawn a process.
- let started: ShellProcess | undefined
const id = jobs.start({
kind: 'pwsh',
label: args.command,
...exec.agent ? { owner: exec.agent } : {},
- run: () => {
+ record: true,
+ run: (job) => {
const proc = ctx.shell.execute(ctx.shell.resolve({ ...request, onExpiry: 'none' }))
- started = proc
+ // The pump's final drain is folded into `done` so the record holds
+ // its last bytes before settlement trims and closes it.
+ const observed = observeProcessRecord(ctx, job, proc, recordPollMs)
return {
cancel: () => void proc.kill(),
- done: proc.done.then(() => processOutcome(proc)),
+ done: observed.then(() => proc.done).then(() => processOutcome(proc)),
readOutput: () => renderPwshProcessRead(proc.readOutput(), proc.sandbox, escalationModes),
}
},
})
- // The starter ran synchronously inside the committed start; mirror the
- // spawned process into the optional observation registry.
- if (started !== undefined) {
- observeBackgroundActivity(ctx, started, {
- kind: 'pwsh',
- label: args.command,
- ...exec.agent ? { owner: exec.agent } : {},
- correlation: { callId: exec.callId, jobId: id },
- }, activityPollMs, processOutcome)
- }
return { kind: 'background' as const, jobId: id }
}
// Promotion needs a live registry at execution time; without one the
@@ -477,11 +461,17 @@ export function apply(ctx: Context, config: Config = {}): void {
kind: 'pwsh',
label: args.command,
...exec.agent ? { owner: exec.agent } : {},
- run: () => ({
- cancel: () => void foreground.kill(),
- done: foreground.done.then(() => processOutcome(foreground)),
- readOutput: () => renderPwshProcessRead(foreground.readOutput(), foreground.sandbox, escalationModes),
- }),
+ record: true,
+ run: (job) => {
+ // The pump's final drain is folded into `done` so the record
+ // holds its last bytes before settlement trims and closes it.
+ const observed = observeProcessRecord(ctx, job, foreground, recordPollMs)
+ return {
+ cancel: () => void foreground.kill(),
+ done: observed.then(() => foreground.done).then(() => processOutcome(foreground)),
+ readOutput: () => renderPwshProcessRead(foreground.readOutput(), foreground.sandbox, escalationModes),
+ }
+ },
})
} catch (error) {
// Admission or controller preflight refused the promotion: fall
@@ -490,12 +480,6 @@ export function apply(ctx: Context, config: Config = {}): void {
}
if (promoted !== undefined) {
offer.accept()
- observeBackgroundActivity(ctx, foreground, {
- kind: 'pwsh',
- label: args.command,
- ...exec.agent ? { owner: exec.agent } : {},
- correlation: { callId: exec.callId, jobId: promoted },
- }, activityPollMs, processOutcome)
// One consuming read seeds the result with the output so far; the
// job's cursor continues exactly after it, no repeat and no gap.
return {
diff --git a/packages/shell/tool-pwsh/src/observe.ts b/packages/shell/tool-pwsh/src/observe.ts
index abf325c6b9..6e9e466981 100644
--- a/packages/shell/tool-pwsh/src/observe.ts
+++ b/packages/shell/tool-pwsh/src/observe.ts
@@ -1,84 +1,46 @@
/**
- * Best-effort process-observation tap for background pwsh runs: mirrors the
- * spawned process's captured streams into `ctx.activities` so non-consuming
- * observers (the Web client) see live output without touching the job's
- * consuming `readOutput` cursor. Absent registry or absent backend offset
- * readers degrade to no observation; the job path is unaffected either way.
+ * Record tap for background pwsh runs: copies the spawned process's captured
+ * streams into the job's observation record, so non-consuming observers (the
+ * Web client) see live output without touching the job's consuming
+ * `readOutput` cursor. Observation is strictly best-effort: absent backend
+ * offset readers degrade to no observation, and a pump failure is logged and
+ * swallowed — the job path is unaffected either way.
*
* @module @deepseek-ai/dsh-tool-pwsh/observe
*/
/* jscpd:ignore-start -- mirrors tool-bash's observe tap by design, like render/background. */
import type { Context } from '@deepseek-ai/cordis'
-import type { Agent } from '@deepseek-ai/dsh-agent'
import type { ShellProcess } from '@deepseek-ai/dsh-shell'
-import { pumpActivityOutput } from '@deepseek-ai/dsh-activity'
-import type { ActivityCorrelation, ActivityKind, ActivityPumpSource } from '@deepseek-ai/dsh-activity'
-
-/** Identity of one observed background run. */
-export interface ObserveBackgroundSpec {
- /** Process kind — the producing tool's registered {@link ActivityKind}. */
- kind: ActivityKind
- /** One-line label (the command). */
- label: string
- /** Owning live agent, when the call has one. */
- owner?: Agent
- /** Links to the starting tool call and the registered job. */
- correlation: ActivityCorrelation
-}
+import { pumpJobOutput } from '@deepseek-ai/dsh-jobs'
+import type { JobPumpSource, RunningJob } from '@deepseek-ai/dsh-jobs'
/**
- * Open a process beside a committed background job and pump the handle's
- * non-consuming offset readers into it until the run settles, then record the
- * mapped outcome. Observation is strictly best-effort: without a
- * `ctx.activities` registry this is a no-op, without backend offset readers
- * the process still carries status and settlement, and ANY observation
- * failure — including a rejected `open()` — is logged and swallowed, because
- * the background job is already committed and running.
- * @param ctx - plugin context used for the optional registry and warning logs.
+ * Pump the process's non-consuming offset readers into the job record until
+ * the run settles, then drain once more. The returned promise never rejects:
+ * a pump failure is logged and the record simply stops advancing, while the
+ * process and its job continue untouched.
+ * @param ctx - plugin context used for the warning log.
+ * @param job - the running job's producer face receiving the copied chunks.
* @param proc - the started background process handle.
- * @param spec - process identity and correlation for the registry row.
* @param pollMs - producer-configured pump cadence in milliseconds.
- * @param outcome - maps the settled handle to the process outcome.
+ * @returns resolves after the final drain; fold into the job's `done` chain so
+ * the record holds its final bytes before settlement closes it.
*/
-export function observeBackgroundActivity(
+export function observeProcessRecord(
ctx: Context,
+ job: RunningJob,
proc: ShellProcess,
- spec: ObserveBackgroundSpec,
pollMs: number,
- outcome: (proc: ShellProcess) => { status: 'completed' | 'killed'; detail: string },
-): void {
- try {
- const processes = ctx.get('activities')
- if (processes === undefined) return
- const handle = processes.open({
- kind: spec.kind,
- label: spec.label,
- ...spec.owner !== undefined ? { owner: spec.owner } : {},
- correlation: spec.correlation,
+): Promise {
+ const sources: JobPumpSource[] = []
+ const stdout = proc.observed?.stdout
+ if (stdout !== undefined) sources.push({ channel: 'stdout', read: from => stdout.readFrom(from) })
+ const stderr = proc.observed?.stderr
+ if (stderr !== undefined) sources.push({ channel: 'stderr', read: from => stderr.readFrom(from) })
+ return pumpJobOutput(job, sources, { pollMs, done: proc.done })
+ .catch((error: unknown) => {
+ ctx.logger.warn(`record observation pump for ${job.id} failed: ${String(error)}`)
})
- const sources: ActivityPumpSource[] = []
- const stdout = proc.observed?.stdout
- if (stdout !== undefined) sources.push({ channel: 'stdout', read: from => stdout.readFrom(from) })
- const stderr = proc.observed?.stderr
- if (stderr !== undefined) sources.push({ channel: 'stderr', read: from => stderr.readFrom(from) })
- void pumpActivityOutput(handle, sources, { pollMs, done: proc.done })
- .catch((error: unknown) => {
- ctx.logger.warn(`activity observation pump for ${handle.id} failed: ${String(error)}`)
- })
- .then(async () => {
- // The pump can fail while the process is still running; the outcome
- // mapping requires a settled handle, and `end` is first-wins, so an
- // early mapping would freeze a wrong terminal state onto the row.
- // `proc.done` never rejects.
- await proc.done
- handle.end(outcome(proc))
- })
- .catch((error: unknown) => {
- ctx.logger.warn(`activity settlement mapping for ${handle.id} failed: ${String(error)}`)
- })
- } catch (error: unknown) {
- ctx.logger.warn(`activity observation unavailable for this run: ${String(error)}`)
- }
}
/* jscpd:ignore-end */
diff --git a/packages/shell/tool-pwsh/tests/observe.spec.ts b/packages/shell/tool-pwsh/tests/observe.spec.ts
index dee23c963f..fcd9de6d6b 100644
--- a/packages/shell/tool-pwsh/tests/observe.spec.ts
+++ b/packages/shell/tool-pwsh/tests/observe.spec.ts
@@ -1,18 +1,17 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
-import type { Agent } from '@deepseek-ai/dsh-agent'
import { ToolCallId } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRuntime from '@deepseek-ai/dsh-tools'
import AgentRegistry from '@deepseek-ai/dsh-agent'
+import type { Agent } from '@deepseek-ai/dsh-agent'
import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local'
import * as ToolTasks from '@deepseek-ai/dsh-tool-jobs'
-import LocalActivityRegistry from '@deepseek-ai/dsh-activity-local'
import { ShellExecutor } from '@deepseek-ai/dsh-shell'
import type { ShellExecRequest, ShellExecSpec, ShellExecution, ShellProcess } from '@deepseek-ai/dsh-shell'
-import * as ToolPwsh from '@deepseek-ai/dsh-tool-pwsh'
import { renderPwshPromoted } from '../src/render.ts'
+import * as ToolPwsh from '@deepseek-ai/dsh-tool-pwsh'
import * as BashEnvPlugin from '@deepseek-ai/dsh-shell-env'
const testToolSignal = new AbortController().signal
@@ -86,10 +85,9 @@ async function setup() {
await ctx.plugin(AgentRegistry)
await ctx.plugin(LocalJobRegistry)
await ctx.plugin(ToolTasks)
- await ctx.plugin(LocalActivityRegistry)
await ctx.plugin(BashEnvPlugin)
await ctx.plugin(FakePwsh)
- await ctx.plugin(ToolPwsh, { activityPollMs: 5 })
+ await ctx.plugin(ToolPwsh, { recordPollMs: 5 })
return { ctx, pwsh: ctx.shell as FakePwsh }
}
@@ -113,8 +111,8 @@ async function until(read: () => T | undefined, timeoutMs = 5_000): Promise {
- it('mirrors observed streams with channels and settles with the mapped outcome', async () => {
+describe('background pwsh record', () => {
+ it('streams observed channels into the job record and settles with the mapped outcome', async () => {
const { ctx, pwsh } = await setup()
const stdout = { text: '' }
const stderr = { text: '' }
@@ -123,29 +121,32 @@ describe('background pwsh observation', () => {
await call(ctx, { command: 'Get-Progress', description: 'test command', run_in_background: true })
const job = ctx.jobs.list()[0]
- const row = await until(() => ctx.activities.list().find(item => item.correlation?.jobId === job!.id))
- expect(row.kind).toBe('pwsh')
+ expect(job).toBeDefined()
+ expect(job!.kind).toBe('pwsh')
+ expect(job!.outputTotal).toBe(0)
stdout.text = 'progress-line\n'
stderr.text = 'warn-line\n'
await until(() => {
- const chunks = ctx.activities.read(row.id, 0).chunks
+ const chunks = ctx.jobs.readRecord(job!.id, 0).chunks
return chunks.some(chunk => chunk.text.includes('progress-line'))
&& chunks.some(chunk => chunk.text.includes('warn-line'))
? true
: undefined
})
- const chunks = ctx.activities.read(row.id, 0).chunks
+ const chunks = ctx.jobs.readRecord(job!.id, 0).chunks
expect(chunks.find(chunk => chunk.text.includes('progress-line'))?.channel).toBe('stdout')
expect(chunks.find(chunk => chunk.text.includes('warn-line'))?.channel).toBe('stderr')
scripted.finish()
- await until(() => ctx.activities.get(row.id).status === 'completed' ? true : undefined)
- expect(ctx.activities.get(row.id).detail).toBe('exit code: 0')
+ await until(() => ctx.jobs.get(job!.id).status === 'completed' ? true : undefined)
+ expect(ctx.jobs.get(job!.id).detail).toBe('exit code: 0')
})
- it('a pump failure waits for process settlement before mapping the outcome', async () => {
+ it('a pump failure warns and never fakes a terminal state onto the running job', async () => {
const { ctx, pwsh } = await setup()
+ const warn = vi.fn()
+ ctx.logger.warn = warn as never
let resolveDone: () => void = () => {}
const done = new Promise((resolve) => { resolveDone = resolve })
const proc: ShellProcess = {
@@ -162,65 +163,60 @@ describe('background pwsh observation', () => {
}
pwsh.backgroundHandler = () => proc
await call(ctx, { command: 'Get-Broken', description: 'test command', run_in_background: true })
- const row = await until(() => ctx.activities.list()[0])
- // The pump rejected on its first poll; the still-running process must not
- // be mapped to a terminal state until it actually settles.
+ const job = ctx.jobs.list()[0]
+ await until(() => warn.mock.calls.some(args => String(args[0]).includes('record observation pump')) ? true : undefined)
+ // The pump already failed; the job must wait for real settlement rather
+ // than freezing a fake terminal state onto the running process.
await new Promise(resolve => setTimeout(resolve, 50))
- expect(ctx.activities.list()[0]?.status).toBe('running')
+ expect(ctx.jobs.get(job!.id).status).toBe('running')
proc.status = 'completed'
proc.exitCode = 0
resolveDone()
- await until(() => ctx.activities.list()[0]?.status !== 'running' ? true : undefined)
- expect(ctx.activities.list()[0]).toMatchObject({ status: 'completed', detail: 'exit code: 0' })
- expect(String(row.id)).toContain('pwsh')
+ await until(() => ctx.jobs.get(job!.id).status !== 'running' ? true : undefined)
+ expect(ctx.jobs.get(job!.id)).toMatchObject({ status: 'completed', detail: 'exit code: 0' })
})
- it('a throwing end() is swallowed by the settlement-mapping catch', async () => {
- const ctx = new Context()
- await ctx.plugin(SystemPrompt)
- await ctx.plugin(ToolRuntime)
- await ctx.plugin(AgentRegistry)
- await ctx.plugin(LocalJobRegistry)
- await ctx.plugin(ToolTasks)
- await ctx.plugin({
- name: 'end-throwing-activities-probe',
- apply(child: Context) {
- child.provide('activities', {
- open: () => ({
- id: 'pwsh-1',
- append() {},
- updateDetail() {},
- end() { throw new Error('end boom') },
- }),
- })
- },
- })
- await ctx.plugin(BashEnvPlugin)
- await ctx.plugin(FakePwsh)
- await ctx.plugin(ToolPwsh, { activityPollMs: 5 })
- const pwsh = ctx.shell as FakePwsh
- const warn = vi.fn()
- ctx.logger.warn = warn as never
- const handle = observableProcess({ stdout: { text: '' }, stderr: { text: '' } })
- pwsh.backgroundHandler = () => handle.proc
- await call(ctx, { command: 'Get-Doom', description: 'test command', run_in_background: true })
- handle.finish()
- await until(() => warn.mock.calls.some(args => String(args[0]).includes('activity settlement mapping')) ? true : undefined)
- })
-
- it('a backend without observed readers still yields a status-only process', async () => {
+ it('a backend without observed readers still yields a status-only record', async () => {
const { ctx, pwsh } = await setup()
const scripted = observableProcess()
pwsh.backgroundHandler = () => scripted.proc
await call(ctx, { command: 'Start-Job', description: 'test command', run_in_background: true })
const job = ctx.jobs.list()[0]
- const row = await until(() => ctx.activities.list().find(item => item.correlation?.jobId === job!.id))
- expect(ctx.activities.read(row.id, 0).chunks).toEqual([])
+ expect(ctx.jobs.readRecord(job!.id, 0).chunks).toEqual([])
scripted.finish()
- await until(() => ctx.activities.get(row.id).status === 'completed' ? true : undefined)
- expect(ctx.activities.get(row.id).outputTotal).toBe(0)
+ await until(() => ctx.jobs.get(job!.id).status === 'completed' ? true : undefined)
+ expect(ctx.jobs.get(job!.id).outputTotal).toBe(0)
+ })
+})
+
+describe('owned background record (pwsh)', () => {
+ it('keeps an owned background run record under the owning session', async () => {
+ const { ctx, pwsh } = await setup()
+ const owner = {
+ id: SessionId('pwsh-observe-owner'),
+ session: { id: SessionId('pwsh-observe-owner'), header: { cwd: process.cwd() } },
+ status: 'idle',
+ ctx,
+ } as unknown as Agent
+ ctx.agents.register(owner)
+ const scripted = observableProcess()
+ pwsh.backgroundHandler = () => scripted.proc
+ await ctx.tools.execute({
+ signal: testToolSignal,
+ callId: ToolCallId('pwsh-observe-owned'),
+ name: 'pwsh',
+ arguments: { command: 'Get-Slow', description: 'test command', run_in_background: true },
+ agent: owner,
+ })
+ const job = ctx.jobs.list(owner)[0]
+ expect(job).toBeDefined()
+ expect(job!.ownerSession).toBe(owner.id)
+ expect(() => ctx.jobs.readRecord(job!.id, 0)).toThrow(/belongs to another session/)
+ expect(ctx.jobs.readRecord(job!.id, 0, owner).chunks).toEqual([])
+ scripted.finish()
+ await until(() => ctx.jobs.get(job!.id, owner).status === 'completed' ? true : undefined)
})
})
@@ -258,7 +254,7 @@ describe('foreground timeout promotion (pwsh)', () => {
return { proc, offer, finish: () => { settle() } }
}
- it('moves a timed-out command into a pwsh job carrying the output so far', async () => {
+ it('moves a timed-out command into a pwsh record job carrying the output so far', async () => {
const { ctx, pwsh } = await setup()
const scripted = promotableExecution('early-output\n')
pwsh.foregroundHandler = () => scripted.proc
@@ -272,6 +268,7 @@ describe('foreground timeout promotion (pwsh)', () => {
const job = ctx.jobs.list()[0]
expect(job).toMatchObject({ id: 'pwsh-1', kind: 'pwsh', status: 'running' })
+ expect(job!.outputTotal).toBeDefined()
// The consuming cursor continued past the promoted output.
expect(ctx.jobs.read(job!.id).text).toBe('')
scripted.finish()
@@ -288,7 +285,7 @@ describe('foreground timeout promotion (pwsh)', () => {
await ctx.plugin(LocalJobRegistry)
await ctx.plugin(BashEnvPlugin)
await ctx.plugin(FakePwsh)
- await ctx.plugin(ToolPwsh, { activityPollMs: 5 })
+ await ctx.plugin(ToolPwsh, { recordPollMs: 5 })
const pwsh = ctx.shell as FakePwsh
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
const scripted = promotableExecution('')
@@ -370,46 +367,3 @@ describe('renderPwshPromoted', () => {
.toContain('line\n[still running after 250ms')
})
})
-
-describe('owned and degraded observation (pwsh)', () => {
- it('mirrors an owned background run under the owning session', async () => {
- const { ctx, pwsh } = await setup()
- const owner = {
- id: SessionId('pwsh-observe-owner'),
- session: { id: SessionId('pwsh-observe-owner'), header: { cwd: process.cwd() } },
- status: 'idle',
- ctx,
- } as unknown as Agent
- ctx.agents.register(owner)
- const scripted = observableProcess()
- pwsh.backgroundHandler = () => scripted.proc
- await ctx.tools.execute({
- signal: testToolSignal,
- callId: ToolCallId('pwsh-observe-owned'),
- name: 'pwsh',
- arguments: { command: 'Get-Slow', description: 'test command', run_in_background: true },
- agent: owner,
- })
- const job = ctx.jobs.list(owner)[0]
- expect(job).toBeDefined()
- const row = await until(() => ctx.activities.list(owner).find(item => item.correlation?.jobId === job!.id))
- expect(row.ownerSession).toBe(owner.id)
- scripted.finish()
- await until(() => ctx.jobs.get(job!.id, owner).status === 'completed' ? true : undefined)
- })
-
- it('swallows a throwing observation registry and keeps the job alive', async () => {
- const { ctx, pwsh } = await setup()
- const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
- vi.spyOn(ctx.activities, 'open').mockImplementation(() => { throw new Error('registry down') })
- const scripted = observableProcess()
- pwsh.backgroundHandler = () => scripted.proc
- await call(ctx, { command: 'Get-Slow', description: 'test command', run_in_background: true })
- const job = ctx.jobs.list()[0]
- expect(job?.status).toBe('running')
- expect(warn.mock.calls.map(args => String(args[0])).join('\n'))
- .toContain('activity observation unavailable for this run')
- scripted.finish()
- await until(() => ctx.jobs.get(job!.id).status === 'completed' ? true : undefined)
- })
-})
diff --git a/packages/shell/tool-pwsh/tsconfig.json b/packages/shell/tool-pwsh/tsconfig.json
index d38c8f031d..59a4d9478c 100644
--- a/packages/shell/tool-pwsh/tsconfig.json
+++ b/packages/shell/tool-pwsh/tsconfig.json
@@ -52,9 +52,6 @@
},
{
"path": "../../runtime-diagnostics/invariants"
- },
- {
- "path": "../../activity/activity"
}
]
}
diff --git a/packages/workflow/tool-workflow/README.i18n.yaml b/packages/workflow/tool-workflow/README.i18n.yaml
index 719235042d..125c16d968 100644
--- a/packages/workflow/tool-workflow/README.i18n.yaml
+++ b/packages/workflow/tool-workflow/README.i18n.yaml
@@ -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/workflow/tool-workflow/README.md
-README.md: a7096838da13f7f744bb1e6179a758d3dedc38a3
-README.zh.md: 254f498b4fe13397a035c97bfd7c708cde3415d7
+README.md: 2291b274a5cd52813266f4cb0e9276cd8019c4f8
+README.zh.md: d3009fa57d2ec2a16ed01c4926bb41066f1bbf2c
diff --git a/packages/workflow/tool-workflow/README.md b/packages/workflow/tool-workflow/README.md
index a7096838da..2291b274a5 100644
--- a/packages/workflow/tool-workflow/README.md
+++ b/packages/workflow/tool-workflow/README.md
@@ -68,7 +68,7 @@ The consumer owns the model-facing schema, the `tool:` system-prompt g
For a root transport execution (`exec.parent` absent), the tool projects the run into the calling Agent's Session with four log-only events: run-start after `start()` returns, member starts and endings filtered by `run.id`, then run-end only after the result is available and disposal reaches quiescence. Nested transport calls execute normally but write no record. The first failed Session append disables later recording for that run with one warning, leaving either no record or a legal continuous prefix without changing the tool result or cleanup. The package invariant rejects duplicate starts, unpaired members, terminal events with open members, and updates after run-end on both cold load and live append, while accepting missing terminal suffixes.
-Beside the durable record, each recorded top-level run is mirrored best-effort into the optional `ctx.activities` registry as a `workflow` activity: the otherwise-unconsumed `workflow/phase` and `workflow/log` events plus member lifecycle lines stream into it, so the Web client shows the run's live narration the session log deliberately does not carry per line. Without the registry, or on any mirror failure, the run is unaffected.
+The engine's `workflow/phase` and `workflow/log` events have no per-line durable or observer surface from this tool: the session log deliberately records run and member lifecycle only, and the Web transcript derives from those records.
### Render intent
@@ -80,7 +80,6 @@ Decided up front per the [render-intent Agent Note](../../../.agents/notes/imple
|---|---|
| [`src/index.ts`](src/index.ts) | Plugin entry: tool registration, run lifecycle, recorder wiring |
| [`src/types.ts`](src/types.ts) | The four log-only record event payloads and their `SessionEventMap` declaration |
-| [`src/activity.ts`](src/activity.ts) | Best-effort activity mirror for top-level runs |
| [`src/invariant.ts`](src/invariant.ts) | Invariant companion: durable workflow-record protocol validation |
diff --git a/packages/workflow/tool-workflow/README.zh.md b/packages/workflow/tool-workflow/README.zh.md
index 254f498b4f..d3009fa57d 100644
--- a/packages/workflow/tool-workflow/README.zh.md
+++ b/packages/workflow/tool-workflow/README.zh.md
@@ -68,7 +68,7 @@ kind: "package-reference"
对于根 transport 执行(`exec.parent` 缺省),工具会用四个 log-only 事件把运行投影到调用 Agent 的 Session:`start()` 返回后写 run-start,只记录 `run.id` 匹配的成员开始与结束,并且只在结果可用且 dispose 完全停稳后写 run-end。嵌套 transport 调用照常执行,但不写任何记录。任一次 Session append 首次失败后,本运行会停止后续记录并只告警一次,留下空记录或合法连续前缀,同时不改变工具结果和清理。包 invariant 会在冷加载与实时追加时拒绝重复 start、未配对成员、仍有开放成员的终点与 run-end 后更新,同时允许缺失终态后缀的连续前缀。
-在持久记录之外,每个被记录的顶层运行还会被尽力镜像进可选的 `ctx.activities` 注册表,作为一个 `workflow` activity:原本无人消费的 `workflow/phase` 与 `workflow/log` 事件加上成员生命周期行流入其中,使 Web 客户端看到会话日志刻意不逐行携带的实时叙述。没有注册表、或镜像出现任何失败时,运行本身不受影响。
+引擎的 `workflow/phase` 与 `workflow/log` 事件在本工具没有逐行的持久或观察面:会话日志刻意只记录 run 与成员生命周期,Web transcript 由这些记录派生。
### 渲染意图
@@ -80,7 +80,6 @@ kind: "package-reference"
|---|---|
| [`src/index.ts`](src/index.ts) | 插件入口:工具注册、运行生命周期、记录器接线 |
| [`src/types.ts`](src/types.ts) | 四个 log-only 记录事件 payload 及其 `SessionEventMap` 声明 |
-| [`src/activity.ts`](src/activity.ts) | 顶层运行的尽力 activity 镜像 |
| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件:持久工作流记录协议校验 |
diff --git a/packages/workflow/tool-workflow/package.json b/packages/workflow/tool-workflow/package.json
index a9707f1456..4f148077b5 100644
--- a/packages/workflow/tool-workflow/package.json
+++ b/packages/workflow/tool-workflow/package.json
@@ -38,7 +38,6 @@
"license": "MIT",
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:^",
- "@deepseek-ai/dsh-activity": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
@@ -52,8 +51,6 @@
},
"devDependencies": {
"@deepseek-ai/cordis": "workspace:^",
- "@deepseek-ai/dsh-activity": "workspace:^",
- "@deepseek-ai/dsh-activity-local": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
diff --git a/packages/workflow/tool-workflow/src/activity.ts b/packages/workflow/tool-workflow/src/activity.ts
deleted file mode 100644
index 36b251b894..0000000000
--- a/packages/workflow/tool-workflow/src/activity.ts
+++ /dev/null
@@ -1,119 +0,0 @@
-/**
- * Best-effort activity mirror for top-level workflow runs: opens one
- * `workflow` activity per run and streams the engine's `workflow/phase`,
- * `workflow/log`, and member lifecycle events into it as text lines, so the
- * Web client shows live progress the session log deliberately does not carry
- * per line. Without a `ctx.activities` registry every operation is a no-op,
- * and ANY mirror failure is logged and swallowed — the run is unaffected.
- *
- * @module @deepseek-ai/dsh-tool-workflow/activity
- */
-
-import type { Context } from '@deepseek-ai/cordis'
-import type { Agent } from '@deepseek-ai/dsh-agent'
-import type { ActivityHandle } from '@deepseek-ai/dsh-activity'
-import type { WorkflowRun, WorkflowRunId, WorkflowStopReason } from '@deepseek-ai/dsh-workflow'
-
-declare module '@deepseek-ai/dsh-activity' {
- interface ActivityKindMap {
- workflow: 'workflow'
- }
-}
-
-/** Activity taps for the three points the tool already tracks a run at. */
-export interface WorkflowActivityMirror {
- /**
- * Open the run's activity. Call once per recorded top-level run.
- * @param run - the started run (id and meta name).
- * @param owner - the calling agent, when the call has one.
- */
- start(run: WorkflowRun, owner: Agent | undefined): void
- /**
- * End the run's activity with the mapped stop reason.
- * @param runId - the settled run.
- * @param stopReason - how the run stopped.
- */
- finish(runId: WorkflowRunId, stopReason: WorkflowStopReason): void
- /**
- * Drop tracking for a run that ends without a settled reason. First-wins
- * with {@link finish}: after a normal finish this is inert.
- * @param runId - the abandoned run.
- */
- abandon(runId: WorkflowRunId): void
-}
-
-/** Map a run's stop reason onto the activity outcome vocabulary. */
-function outcomeOf(stopReason: WorkflowStopReason): { status: 'completed' | 'failed' | 'killed'; detail: string } {
- switch (stopReason) {
- case 'completed': return { status: 'completed', detail: 'completed' }
- case 'cancelled': return { status: 'killed', detail: 'cancelled' }
- case 'error': return { status: 'failed', detail: 'error' }
- }
-}
-
-/**
- * Create the run-to-activity mirror and subscribe the engine's live progress
- * events for the runs it tracks.
- * @param ctx - plugin context used for the optional registry and warning logs.
- * @returns the mirror taps the tool wires beside its durable recorder.
- */
-export function createWorkflowActivityMirror(ctx: Context): WorkflowActivityMirror {
- const active = new Map()
- const contained = (operation: string, act: () => void): void => {
- try {
- act()
- } catch (error: unknown) {
- ctx.logger.warn(`workflow activity mirror ${operation} failed: ${String(error)}`)
- }
- }
-
- ctx.on('workflow/phase', (info, title) => {
- const handle = active.get(info.id)
- if (handle === undefined) return
- contained('phase', () => {
- handle.updateDetail(title)
- handle.append(`▸ ${title}\n`)
- })
- })
- ctx.on('workflow/log', (info, message) => {
- const handle = active.get(info.id)
- if (handle === undefined) return
- contained('log', () => { handle.append(`${message}\n`) })
- })
- ctx.on('workflow/agent-start', (info, agent) => {
- const handle = active.get(info.id)
- if (handle === undefined) return
- contained('agent-start', () => { handle.append(`agent #${agent.seq} ${agent.label} started\n`) })
- })
- ctx.on('workflow/agent-end', (info, agent) => {
- const handle = active.get(info.id)
- if (handle === undefined) return
- contained('agent-end', () => { handle.append(`agent #${agent.seq} ${agent.outcome}\n`) })
- })
-
- return {
- start(run, owner) {
- contained('open', () => {
- const activities = ctx.get('activities')
- if (activities === undefined) return
- active.set(run.id, activities.open({
- kind: 'workflow',
- label: run.meta.name,
- ...owner !== undefined ? { owner } : {},
- }))
- })
- },
- finish(runId, stopReason) {
- const handle = active.get(runId)
- active.delete(runId)
- if (handle === undefined) return
- contained('finish', () => { handle.end(outcomeOf(stopReason)) })
- },
- abandon(runId) {
- const handle = active.get(runId)
- active.delete(runId)
- if (handle === undefined) return
- contained('abandon', () => { handle.end({ status: 'killed', detail: 'run abandoned' }) })
- },
- }
-}
diff --git a/packages/workflow/tool-workflow/src/index.ts b/packages/workflow/tool-workflow/src/index.ts
index 022d8e4266..77e21672b4 100644
--- a/packages/workflow/tool-workflow/src/index.ts
+++ b/packages/workflow/tool-workflow/src/index.ts
@@ -24,7 +24,6 @@ import type {
ToolWorkflowAgentEndData, ToolWorkflowAgentStartData,
ToolWorkflowRunEndData, ToolWorkflowRunStartData,
} from './types.ts'
-import { createWorkflowActivityMirror } from './activity.ts'
export const name = 'tool-workflow'
export const inject = ['tools', 'workflowEngine', 'systemPrompt']
@@ -207,7 +206,6 @@ export function apply(ctx: Context, config: Config): void {
// fields; the assertion records that resolution, not a hidden fallback.
const { toolName, maxResultChars } = config as ResolvedConfig
const recorder = createWorkflowRecorder(ctx)
- const mirror = createWorkflowActivityMirror(ctx)
// Usage policy ships with the tool (the master convention: tool guidance
// lives in tool plugins as prompt sections, not in the deployment persona).
ctx.systemPrompt.section({
@@ -294,7 +292,6 @@ export function apply(ctx: Context, config: Config): void {
// worker messages, after start() returns and this run record is active.
if (recordsRun) {
recorder.start(parent.session, run)
- mirror.start(run, parent)
}
// Bridge the tool's abort signal to the run: if the parent step is aborted while the
@@ -327,12 +324,10 @@ export function apply(ctx: Context, config: Config): void {
/* v8 ignore next -- WorkflowRun.result never rejects by contract, so result is assigned before finally. */
if (result === undefined) throw new Error('workflow run settled without a result')
recorder.finish(run.id, result.stopReason)
- mirror.finish(run.id, result.stopReason)
}
} finally {
if (recordsRun) {
recorder.abandon(run.id)
- mirror.abandon(run.id)
}
}
}
diff --git a/packages/workflow/tool-workflow/tests/activity-mirror.spec.ts b/packages/workflow/tool-workflow/tests/activity-mirror.spec.ts
deleted file mode 100644
index b3b5afff49..0000000000
--- a/packages/workflow/tool-workflow/tests/activity-mirror.spec.ts
+++ /dev/null
@@ -1,129 +0,0 @@
-import { describe, expect, it, vi } from 'vitest'
-import { Context } from '@deepseek-ai/cordis'
-import AgentRegistry from '@deepseek-ai/dsh-agent'
-import type { Agent } from '@deepseek-ai/dsh-agent'
-import LocalActivityRegistry from '@deepseek-ai/dsh-activity-local'
-import { SessionId } from '@deepseek-ai/dsh-session'
-import type { WorkflowRun, WorkflowRunId, WorkflowRunInfo } from '@deepseek-ai/dsh-workflow'
-import { createWorkflowActivityMirror } from '../src/activity.ts'
-
-function runStub(id = 'run-1'): { run: WorkflowRun; info: WorkflowRunInfo } {
- const info = { id: id as WorkflowRunId, meta: { name: 'docs-sweep' } } as WorkflowRunInfo
- return {
- run: { id: id as WorkflowRunId, meta: { name: 'docs-sweep' } } as WorkflowRun,
- info,
- }
-}
-
-function retainedText(ctx: Context): string {
- const row = ctx.activities.list()[0]
- if (row === undefined) throw new Error('no mirrored activity')
- return ctx.activities.read(row.id, 0).chunks.map(chunk => chunk.text).join('')
-}
-
-describe('workflow activity mirror', () => {
- it('opens one workflow activity per run and streams phase, log, and member lines', async () => {
- const ctx = new Context()
- await ctx.plugin(LocalActivityRegistry)
- const mirror = createWorkflowActivityMirror(ctx)
- const { run, info } = runStub()
- mirror.start(run, undefined)
-
- const row = ctx.activities.list()[0]
- expect(row?.kind).toBe('workflow')
- expect(row?.label).toBe('docs-sweep')
-
- ctx.emit('workflow/phase', info, 'Scan')
- ctx.emit('workflow/log', info, '3 files queued')
- ctx.emit('workflow/agent-start', info, { seq: 1, label: 'reader', childId: 'c1' } as never)
- ctx.emit('workflow/agent-end', info, { seq: 1, label: 'reader', childId: 'c1', outcome: 'completed' } as never)
- expect(ctx.activities.get(row!.id).detail).toBe('Scan')
- expect(retainedText(ctx)).toBe('▸ Scan\n3 files queued\nagent #1 reader started\nagent #1 completed\n')
-
- mirror.finish(run.id, 'completed')
- const settled = ctx.activities.get(row!.id)
- expect(settled.status).toBe('completed')
- // The always-run abandon after a normal finish is inert.
- mirror.abandon(run.id)
- expect(ctx.activities.get(row!.id).status).toBe('completed')
- })
-
- it('opens an owned run under the owning agent session', async () => {
- const ctx = new Context()
- await ctx.plugin(AgentRegistry)
- await ctx.plugin(LocalActivityRegistry)
- const owner = {
- id: SessionId('workflow-owner'),
- session: { id: SessionId('workflow-owner') },
- status: 'idle',
- ctx,
- } as unknown as Agent
- ctx.agents.register(owner)
- const mirror = createWorkflowActivityMirror(ctx)
- const { run } = runStub('run-owned')
- mirror.start(run, owner)
- // The owned row is fenced to its owner's view.
- const row = ctx.activities.list(owner)[0]
- expect(row?.ownerSession).toBe(owner.id)
- mirror.finish(run.id, 'completed')
- })
-
- it('maps cancelled and error stops, and ends an abandoned run as killed', async () => {
- const ctx = new Context()
- await ctx.plugin(LocalActivityRegistry)
- const mirror = createWorkflowActivityMirror(ctx)
-
- const cancelled = runStub('run-c')
- mirror.start(cancelled.run, undefined)
- mirror.finish(cancelled.run.id, 'cancelled')
-
- const failed = runStub('run-e')
- mirror.start(failed.run, undefined)
- mirror.finish(failed.run.id, 'error')
-
- const orphaned = runStub('run-o')
- mirror.start(orphaned.run, undefined)
- mirror.abandon(orphaned.run.id)
-
- const byLabelOrder = ctx.activities.list()
- expect(byLabelOrder.map(row => [row.status, row.detail])).toEqual([
- ['killed', 'cancelled'],
- ['failed', 'error'],
- ['killed', 'run abandoned'],
- ])
- })
-
- it('ignores events for untracked runs and stays inert without the registry', async () => {
- const bare = new Context()
- const inert = createWorkflowActivityMirror(bare)
- const { run, info } = runStub()
- // No registry: every tap is a no-op rather than a throw.
- inert.start(run, undefined)
- bare.emit('workflow/log', info, 'dropped')
- inert.finish(run.id, 'completed')
- inert.abandon(run.id)
-
- const ctx = new Context()
- await ctx.plugin(LocalActivityRegistry)
- createWorkflowActivityMirror(ctx)
- // Events for a run the mirror never opened change nothing.
- ctx.emit('workflow/phase', info, 'ghost')
- expect(ctx.activities.list()).toEqual([])
- })
-
- it('contains a throwing registry so the run path never breaks', async () => {
- const ctx = new Context()
- const warn = vi.fn()
- ctx.logger.warn = warn as never
- await ctx.plugin({
- name: 'broken-activities-probe',
- apply(child: Context) {
- child.provide('activities', { open() { throw new Error('mirror boom') } })
- },
- })
- const mirror = createWorkflowActivityMirror(ctx)
- const { run } = runStub()
- expect(() => { mirror.start(run, undefined) }).not.toThrow()
- expect(warn).toHaveBeenCalledWith(expect.stringContaining('workflow activity mirror open failed'))
- })
-})
diff --git a/packages/workflow/tool-workflow/tsconfig.json b/packages/workflow/tool-workflow/tsconfig.json
index a4cde5cbef..bfd38e61d4 100644
--- a/packages/workflow/tool-workflow/tsconfig.json
+++ b/packages/workflow/tool-workflow/tsconfig.json
@@ -37,9 +37,6 @@
},
{
"path": "../../runtime-diagnostics/invariants"
- },
- {
- "path": "../../activity/activity"
}
]
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 3fa0e29b81..d938573ee5 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -625,110 +625,6 @@ importers:
specifier: workspace:^
version: link:../../interaction/user-approval
- packages/activity/activity:
- devDependencies:
- '@deepseek-ai/cordis':
- specifier: workspace:^
- version: link:../../../vendor/cordis
- '@deepseek-ai/dsh-agent':
- specifier: workspace:^
- version: link:../../core/agent
- '@deepseek-ai/dsh-brand':
- specifier: workspace:^
- version: link:../../util/brand
- '@deepseek-ai/dsh-invariants':
- specifier: workspace:^
- version: link:../../runtime-diagnostics/invariants
- '@deepseek-ai/dsh-jobs':
- specifier: workspace:^
- version: link:../../jobs/jobs
- '@deepseek-ai/dsh-llm':
- specifier: workspace:^
- version: link:../../llm/llm
- '@deepseek-ai/dsh-session':
- specifier: workspace:^
- version: link:../../core/session
-
- packages/activity/activity-local:
- dependencies:
- '@deepseek-ai/schemastery':
- specifier: link:../../../vendor/schemastery
- version: link:../../../vendor/schemastery
- devDependencies:
- '@deepseek-ai/cordis':
- specifier: workspace:^
- version: link:../../../vendor/cordis
- '@deepseek-ai/cordis-plugin-include':
- specifier: workspace:^
- version: link:../../../vendor/include
- '@deepseek-ai/cordis-plugin-loader':
- specifier: workspace:^
- version: link:../../../vendor/loader
- '@deepseek-ai/dsh-activity':
- specifier: workspace:^
- version: link:../activity
- '@deepseek-ai/dsh-agent':
- specifier: workspace:^
- version: link:../../core/agent
- '@deepseek-ai/dsh-brand':
- specifier: workspace:^
- version: link:../../util/brand
- '@deepseek-ai/dsh-invariants':
- specifier: workspace:^
- version: link:../../runtime-diagnostics/invariants
- '@deepseek-ai/dsh-scope':
- specifier: workspace:^
- version: link:../../core/scope
- '@deepseek-ai/dsh-session':
- specifier: workspace:^
- version: link:../../core/session
-
- packages/api/activity-controller:
- dependencies:
- '@deepseek-ai/schemastery':
- specifier: link:../../../vendor/schemastery
- version: link:../../../vendor/schemastery
- zod:
- specifier: ^4.4.3
- version: 4.4.3
- devDependencies:
- '@deepseek-ai/cordis':
- specifier: workspace:^
- version: link:../../../vendor/cordis
- '@deepseek-ai/dsh-activity':
- specifier: workspace:^
- version: link:../../activity/activity
- '@deepseek-ai/dsh-activity-local':
- specifier: workspace:^
- version: link:../../activity/activity-local
- '@deepseek-ai/dsh-agent':
- specifier: workspace:^
- version: link:../../core/agent
- '@deepseek-ai/dsh-api-gateway':
- specifier: workspace:^
- version: link:../gateway
- '@deepseek-ai/dsh-client-connection':
- specifier: workspace:^
- version: link:../../client/connection
- '@deepseek-ai/dsh-client-store':
- specifier: workspace:^
- version: link:../../client/store
- '@deepseek-ai/dsh-invariants':
- specifier: workspace:^
- version: link:../../runtime-diagnostics/invariants
- '@deepseek-ai/dsh-jobs':
- specifier: workspace:^
- version: link:../../jobs/jobs
- '@deepseek-ai/dsh-llm':
- specifier: workspace:^
- version: link:../../llm/llm
- '@deepseek-ai/dsh-session':
- specifier: workspace:^
- version: link:../../core/session
- '@deepseek-ai/dsh-typert-protocol':
- specifier: workspace:^
- version: link:../../typert/protocol
-
packages/api/gateway:
dependencies:
'@deepseek-ai/dsh-deque':
@@ -793,9 +689,6 @@ importers:
'@deepseek-ai/dsh-agent-presets':
specifier: workspace:^
version: link:../../preset/agent-presets
- '@deepseek-ai/dsh-api-activity-controller':
- specifier: workspace:^
- version: link:../activity-controller
'@deepseek-ai/dsh-api-gateway':
specifier: workspace:^
version: link:../gateway
@@ -1174,9 +1067,6 @@ importers:
'@deepseek-ai/cordis-plugin-timer':
specifier: workspace:^
version: link:../../../vendor/timer
- '@deepseek-ai/dsh-activity-local':
- specifier: workspace:^
- version: link:../../activity/activity-local
'@deepseek-ai/dsh-agent':
specifier: workspace:^
version: link:../../core/agent
@@ -1571,9 +1461,6 @@ importers:
'@deepseek-ai/dsh-agent-presets':
specifier: workspace:^
version: link:../../preset/agent-presets
- '@deepseek-ai/dsh-api-activity-controller':
- specifier: workspace:^
- version: link:../../api/activity-controller
'@deepseek-ai/dsh-api-remotes':
specifier: workspace:^
version: link:../../api/remotes
@@ -1601,9 +1488,6 @@ importers:
'@deepseek-ai/dsh-client-modules':
specifier: workspace:^
version: link:../../client/modules
- '@deepseek-ai/dsh-client-ui-activity':
- specifier: workspace:^
- version: link:../../client/ui-activity
'@deepseek-ai/dsh-client-ui-agent-preset':
specifier: workspace:^
version: link:../../client/ui-agent-preset
@@ -1643,6 +1527,9 @@ importers:
'@deepseek-ai/dsh-client-ui-input-trigger':
specifier: workspace:^
version: link:../../client/ui-input-trigger
+ '@deepseek-ai/dsh-client-ui-jobs':
+ specifier: workspace:^
+ version: link:../../client/ui-jobs
'@deepseek-ai/dsh-client-ui-layout':
specifier: workspace:^
version: link:../../client/ui-layout
@@ -1961,51 +1848,6 @@ importers:
specifier: workspace:^
version: link:../../runtime-diagnostics/invariants
- packages/client/ui-activity:
- devDependencies:
- '@deepseek-ai/cordis':
- specifier: workspace:^
- version: link:../../../vendor/cordis
- '@deepseek-ai/dsh-api-activity-controller':
- specifier: workspace:^
- version: link:../../api/activity-controller
- '@deepseek-ai/dsh-api-session-controller':
- specifier: workspace:^
- version: link:../../api/session-controller
- '@deepseek-ai/dsh-client-locale':
- specifier: workspace:^
- version: link:../locale
- '@deepseek-ai/dsh-client-test-runtime':
- specifier: workspace:^
- version: link:../../test-support/client-runtime
- '@deepseek-ai/dsh-client-ui-conversation':
- specifier: workspace:^
- version: link:../ui-conversation
- '@deepseek-ai/dsh-client-ui-primitives':
- specifier: workspace:^
- version: link:../ui-primitives
- '@deepseek-ai/dsh-client-ui-renderer':
- specifier: workspace:^
- version: link:../ui-renderer
- '@deepseek-ai/dsh-client-ui-session':
- specifier: workspace:^
- version: link:../ui-session
- '@deepseek-ai/dsh-client-ui-slots':
- specifier: workspace:^
- version: link:../ui-slots
- '@deepseek-ai/dsh-invariants':
- specifier: workspace:^
- version: link:../../runtime-diagnostics/invariants
- '@deepseek-ai/dsh-session':
- specifier: workspace:^
- version: link:../../core/session
- '@types/react':
- specifier: ~18.3.1
- version: 18.3.31
- react:
- specifier: ^18.2.0
- version: 18.3.1
-
packages/client/ui-agent-preset:
devDependencies:
'@deepseek-ai/cordis':
@@ -2726,6 +2568,48 @@ importers:
specifier: ^18.2.0
version: 18.3.1
+ packages/client/ui-jobs:
+ devDependencies:
+ '@deepseek-ai/cordis':
+ specifier: workspace:^
+ version: link:../../../vendor/cordis
+ '@deepseek-ai/dsh-api-session-controller':
+ specifier: workspace:^
+ version: link:../../api/session-controller
+ '@deepseek-ai/dsh-client-locale':
+ specifier: workspace:^
+ version: link:../locale
+ '@deepseek-ai/dsh-client-test-runtime':
+ specifier: workspace:^
+ version: link:../../test-support/client-runtime
+ '@deepseek-ai/dsh-client-ui-conversation':
+ specifier: workspace:^
+ version: link:../ui-conversation
+ '@deepseek-ai/dsh-client-ui-primitives':
+ specifier: workspace:^
+ version: link:../ui-primitives
+ '@deepseek-ai/dsh-client-ui-renderer':
+ specifier: workspace:^
+ version: link:../ui-renderer
+ '@deepseek-ai/dsh-client-ui-session':
+ specifier: workspace:^
+ version: link:../ui-session
+ '@deepseek-ai/dsh-client-ui-slots':
+ specifier: workspace:^
+ version: link:../ui-slots
+ '@deepseek-ai/dsh-invariants':
+ specifier: workspace:^
+ version: link:../../runtime-diagnostics/invariants
+ '@deepseek-ai/dsh-session':
+ specifier: workspace:^
+ version: link:../../core/session
+ '@types/react':
+ specifier: ~18.3.1
+ version: 18.3.31
+ react:
+ specifier: ^18.2.0
+ version: 18.3.1
+
packages/client/ui-layout:
devDependencies:
'@deepseek-ai/cordis':
@@ -8291,9 +8175,6 @@ importers:
'@deepseek-ai/cordis':
specifier: workspace:^
version: link:../../../vendor/cordis
- '@deepseek-ai/dsh-activity':
- specifier: workspace:^
- version: link:../../activity/activity
'@deepseek-ai/dsh-agent':
specifier: workspace:^
version: link:../../core/agent
@@ -8419,9 +8300,6 @@ importers:
'@deepseek-ai/cordis':
specifier: workspace:^
version: link:../../../vendor/cordis
- '@deepseek-ai/dsh-activity':
- specifier: workspace:^
- version: link:../../activity/activity
'@deepseek-ai/dsh-agent':
specifier: workspace:^
version: link:../../core/agent
@@ -10408,12 +10286,6 @@ importers:
'@deepseek-ai/cordis':
specifier: workspace:^
version: link:../../../vendor/cordis
- '@deepseek-ai/dsh-activity':
- specifier: workspace:^
- version: link:../../activity/activity
- '@deepseek-ai/dsh-activity-local':
- specifier: workspace:^
- version: link:../../activity/activity-local
'@deepseek-ai/dsh-agent':
specifier: workspace:^
version: link:../../core/agent
@@ -10578,9 +10450,6 @@ importers:
'@deepseek-ai/dsh-acp':
specifier: workspace:^
version: link:../../packages/acp/acp
- '@deepseek-ai/dsh-activity':
- specifier: workspace:^
- version: link:../../packages/activity/activity
'@deepseek-ai/dsh-agent':
specifier: workspace:^
version: link:../../packages/core/agent
diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json
index 2ae2320a08..f735144468 100644
--- a/python/sdk-runtime/package.json
+++ b/python/sdk-runtime/package.json
@@ -13,7 +13,6 @@
"@deepseek-ai/cosmokit": "workspace:^",
"@deepseek-ai/dsh": "workspace:^",
"@deepseek-ai/dsh-acp": "workspace:^",
- "@deepseek-ai/dsh-activity": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-instructions": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts
index ce1744edbd..0bcfc6ca77 100644
--- a/scripts/gen-cordis-catalog.ts
+++ b/scripts/gen-cordis-catalog.ts
@@ -109,8 +109,6 @@ export const SERVICE_PAGE: Record = {
subprocess: 'subprocess.md',
systemPrompt: 'system-prompt.md',
jobs: 'jobs.md',
- activities: 'activity.md',
- activityController: 'activity.md',
sessionTelemetry: 'session-telemetry.md',
agentTeams: 'agent-team.md',
tokenMeter: 'token-meter.md',
@@ -175,7 +173,7 @@ export const SERVICE_WALK_EXEMPTIONS: Record = {
slots: 'client-side interface-typed browser service — packages/client/ui-renderer/README.md owns the API',
theme: 'client-side interface-typed browser service — packages/client/ui-theme/README.md owns the API',
workspaces: 'client-side interface-typed browser service — packages/api/workspace-controller/README.md owns the API',
- activityFeed: 'client-side interface-typed browser service — packages/api/activity-controller/README.md owns the API',
+ jobOutput: 'client-side interface-typed browser service — packages/api/session-controller/README.md owns the API',
}
/**
@@ -324,6 +322,8 @@ export const LINK_MAP: Readonly> = {
SessionId: 'core.md',
SessionListRequest: 'session.md',
SessionListValue: 'session.md',
+ SessionObserveJobFrame: 'session.md',
+ SessionObserveJobRequest: 'session.md',
ModelCatalog: 'session.md',
SessionOpenWorkspacePathRequest: 'session.md',
SessionOpenWorkspacePathValue: 'session.md',
@@ -505,16 +505,6 @@ export const LINK_MAP: Readonly> = {
PromptSectionOrderName: 'system-prompt.md',
SystemPrompt: 'system-prompt.md',
ToolProviderResult: 'system-prompt.md',
- ActivitiesChangedListener: 'activity.md',
- ActivityHandle: 'activity.md',
- ActivityId: 'activity.md',
- ActivityOpen: 'activity.md',
- ActivityOutputListener: 'activity.md',
- ActivityRead: 'activity.md',
- ActivitySnapshot: 'activity.md',
- ActivityControlFrame: 'activity.md',
- ActivityObserveFrame: 'activity.md',
- ActivityObserveRequest: 'activity.md',
JobDoneListener: 'jobs.md',
JobId: 'jobs.md',
JobKillOptions: 'jobs.md',
@@ -522,6 +512,12 @@ export const LINK_MAP: Readonly> = {
JobSnapshot: 'jobs.md',
JobStart: 'jobs.md',
JobsChangedListener: 'jobs.md',
+ JobAppendOptions: 'jobs.md',
+ JobChannel: 'jobs.md',
+ JobOutputListener: 'jobs.md',
+ JobRecordChunk: 'jobs.md',
+ JobRecordRead: 'jobs.md',
+ RunningJob: 'jobs.md',
CreateTeamTaskRequest: 'agent-team.md',
SendTeamMessageRequest: 'agent-team.md',
SendTeamMessageResult: 'agent-team.md',
diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts
index 008991a00c..3638b0c002 100644
--- a/scripts/gen-doc-graphs.ts
+++ b/scripts/gen-doc-graphs.ts
@@ -198,13 +198,6 @@ const SERVICE_ROLES: ServiceRole[] = [
mode: 'core',
note: 'Carries the picking seam onto the wire: capability gating, cancellation, and the seam-coded failures a browser directory flow discriminates on.',
},
- {
- key: 'activityController',
- pkg: 'api-activity-controller',
- title: 'Host Activity Remote controller',
- mode: 'core',
- note: 'Owns the live-activity roster stream and per-activity non-consuming output observation through the generated Remote namespace.',
- },
{
key: 'invariants',
pkg: 'invariants',
@@ -583,17 +576,8 @@ const SERVICE_ROLES: ServiceRole[] = [
title: 'Background job registry',
mode: 'seam',
implementations: ['jobs-local'],
- consumers: ['tool-bash', 'tool-terminal', 'tool-subagent', 'tool-jobs'],
- note: 'Producers (background bash, PTY sends, and subagent delegations) register running work; tool-jobs is the model-facing controller that reads, lists, and kills it; jobs-local is the process-local registry.',
- },
- {
- key: 'activities',
- pkg: 'activity',
- title: 'Live activity observation registry',
- mode: 'seam',
- implementations: ['activity-local'],
- consumers: ['tool-bash', 'tool-pwsh', 'tool-workflow', 'activity-controller'],
- note: 'Producers mirror live output and status into non-consuming offset-addressed streams; activity-controller serves them to browsers, and the model-facing paths (jobs cursors, tool results) stay untouched.',
+ consumers: ['tool-bash', 'tool-pwsh', 'tool-terminal', 'tool-subagent', 'tool-jobs'],
+ note: 'Producers (background bash/pwsh, PTY sends, and subagent delegations) register running work; record-declaring jobs additionally stream raw output for non-consuming observers; tool-jobs is the model-facing controller that reads, lists, and kills it; jobs-local is the process-local registry.',
},
{
key: 'web',
diff --git a/scripts/package-dependency-policy.ts b/scripts/package-dependency-policy.ts
index 15b481383c..63054fd183 100644
--- a/scripts/package-dependency-policy.ts
+++ b/scripts/package-dependency-policy.ts
@@ -5,7 +5,6 @@ const CLIENT_FACE_INCLUDE: readonly string[] = []
/** Packages exempted from automatic Client/Host treatment despite declaring `dsh.client`. */
const CLIENT_FACE_EXCLUDE: readonly string[] = [
- '@deepseek-ai/dsh-api-activity-controller',
'@deepseek-ai/dsh-api-session-controller',
'@deepseek-ai/dsh-api-workspace-controller',
]
diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json
index 010c35a328..a217b3c619 100644
--- a/scripts/type-equiv.manifest.json
+++ b/scripts/type-equiv.manifest.json
@@ -1016,6 +1016,21 @@
"symbol": "JobRead",
"source": "packages/jobs/jobs/src/types.ts"
},
+ {
+ "doc": "docs/subsystems/jobs.md",
+ "symbol": "RunningJob",
+ "source": "packages/jobs/jobs/src/types.ts"
+ },
+ {
+ "doc": "docs/subsystems/jobs.md",
+ "symbol": "JobRecordChunk",
+ "source": "packages/jobs/jobs/src/types.ts"
+ },
+ {
+ "doc": "docs/subsystems/jobs.md",
+ "symbol": "JobRecordRead",
+ "source": "packages/jobs/jobs/src/types.ts"
+ },
{
"doc": "docs/subsystems/terminal.md",
"symbol": "TerminalWaitReason",
diff --git a/scripts/verify-package-dependencies.spec.ts b/scripts/verify-package-dependencies.spec.ts
index 26263fe018..4294cff84b 100644
--- a/scripts/verify-package-dependencies.spec.ts
+++ b/scripts/verify-package-dependencies.spec.ts
@@ -123,7 +123,6 @@ function hostRuntimeFixture(): {
describe('package dependency scope', () => {
it('keeps the measured Host relay roster explicit', () => {
expect(PACKAGE_DEPENDENCY_POLICY.clientFaceExclude).toEqual([
- '@deepseek-ai/dsh-api-activity-controller',
'@deepseek-ai/dsh-api-session-controller',
'@deepseek-ai/dsh-api-workspace-controller',
])
diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts
index ec942708ea..a563dc46bd 100644
--- a/scripts/verify-package-readme-model-experience.ts
+++ b/scripts/verify-package-readme-model-experience.ts
@@ -46,10 +46,7 @@ const NO_MODEL_EXPERIENCE_SECTION: Readonly> = {
const SENTENCE_MODEL_EXPERIENCE: Readonly> = {
'packages/attachment/attachment': { kind: 'indirect', reason: 'The storage seam delegates model request rendering to provider adapters.' },
'packages/attachment/attachment-local': { kind: 'indirect', reason: 'The local backend delegates model request rendering to provider adapters.' },
- 'packages/activity/activity': { kind: 'none', reason: 'Pure observation seam for humans; activity output becomes model-visible only through producers\' existing tool results and jobs reads.' },
- 'packages/activity/activity-local': { kind: 'none', reason: 'In-memory observation provider; producers own every model-visible surface.' },
- 'packages/api/activity-controller': { kind: 'none', reason: 'Browser/Host observation transport; registers no prompt, tool, or session event.' },
- 'packages/client/ui-activity': { kind: 'none', reason: 'Browser-side presentation of host-observed output; touches no prompt, message, schema, stream, or tool result.' },
+ 'packages/client/ui-jobs': { kind: 'none', reason: 'Browser-side presentation of host-observed output; touches no prompt, message, schema, stream, or tool result.' },
'packages/shell/shell': { kind: 'indirect', reason: 'The service interface delegates all model rendering to dsh-tool-bash.' },
'packages/shell/shell-env': { kind: 'indirect', reason: 'The env service exposes managed DSH_* facts through the shell tools (dsh-tool-bash/dsh-tool-pwsh); it registers no prompt or schema of its own.' },
'packages/shell/bash-local': { kind: 'indirect', reason: 'The executor backend delegates model rendering to dsh-tool-bash.' },
diff --git a/snapshots/web/background-job-list/running.expected.md b/snapshots/web/background-job-list/running.expected.md
index 08b4d60f40..280deae286 100644
--- a/snapshots/web/background-job-list/running.expected.md
+++ b/snapshots/web/background-job-list/running.expected.md
@@ -1,4 +1,4 @@
-- list "Tasks":
+- list "Background jobs":
- listitem:
- button "Show live output of sleep 45":
- text: sleep 45 bash {{duration}}
diff --git a/snapshots/web/background-job-list/settled.expected.md b/snapshots/web/background-job-list/settled.expected.md
index cd05488d7f..eb72583ebd 100644
--- a/snapshots/web/background-job-list/settled.expected.md
+++ b/snapshots/web/background-job-list/settled.expected.md
@@ -1,4 +1,4 @@
-- list "Tasks":
+- list "Background jobs":
- listitem:
- button "Finished 1" [expanded]:
- img
diff --git a/tsconfig.base.json b/tsconfig.base.json
index c09254eec2..d8ba3b6830 100644
--- a/tsconfig.base.json
+++ b/tsconfig.base.json
@@ -47,13 +47,6 @@
"@deepseek-ai/dsh-api-gateway/client": ["./packages/api/gateway/src/client/index.ts"],
"@deepseek-ai/dsh-api-gateway/invariant": ["./packages/api/gateway/src/invariant.ts"],
"@deepseek-ai/dsh-api-gateway/types": ["./packages/api/gateway/src/types.ts"],
- "@deepseek-ai/dsh-api-activity-controller": ["./packages/api/activity-controller/src/index.ts"],
- "@deepseek-ai/dsh-api-activity-controller/client": ["./packages/api/activity-controller/src/client/index.ts"],
- "@deepseek-ai/dsh-api-activity-controller/invariant": ["./packages/api/activity-controller/src/invariant.ts"],
- "@deepseek-ai/dsh-api-activity-controller/types": ["./packages/api/activity-controller/src/types.ts"],
- "@deepseek-ai/dsh-client-ui-activity": ["./packages/client/ui-activity/src"],
- "@deepseek-ai/dsh-client-ui-activity/client": ["./packages/client/ui-activity/src/client/index.ts"],
- "@deepseek-ai/dsh-client-ui-activity/invariant": ["./packages/client/ui-activity/src/invariant.ts"],
"@deepseek-ai/dsh-api-session-controller": ["./packages/api/session-controller/src/index.ts"],
"@deepseek-ai/dsh-api-session-controller/client": ["./packages/api/session-controller/src/client/index.ts"],
"@deepseek-ai/dsh-api-session-controller/invariant": ["./packages/api/session-controller/src/invariant.ts"],
@@ -108,7 +101,6 @@
"@deepseek-ai/dsh-commands/brand": ["./packages/interaction/commands/src/brand.ts"],
"@deepseek-ai/dsh-commands/types": ["./packages/interaction/commands/src/types.ts"],
"@deepseek-ai/dsh-jobs/brand": ["./packages/jobs/jobs/src/brand.ts"],
- "@deepseek-ai/dsh-activity/brand": ["./packages/activity/activity/src/brand.ts"],
"@deepseek-ai/dsh-credentials/types": ["./packages/credentials/credentials/src/types.ts"],
"@deepseek-ai/dsh-authorization/types": ["./packages/credentials/authorization/src/types.ts"],
"@deepseek-ai/dsh-settings/types": ["./packages/settings/settings/src/types.ts"],
@@ -212,6 +204,8 @@
"@deepseek-ai/dsh-experimental-client-ui-agent-team": ["./packages/experimental/client-ui-agent-team/src"],
"@deepseek-ai/dsh-client-ui-settings-plugins": ["./packages/client/ui-settings-plugins/src"],
"@deepseek-ai/dsh-client-ui-jobs": ["./packages/client/ui-jobs/src"],
+ "@deepseek-ai/dsh-client-ui-jobs/client": ["./packages/client/ui-jobs/src/client/index.ts"],
+ "@deepseek-ai/dsh-client-ui-jobs/invariant": ["./packages/client/ui-jobs/src/invariant.ts"],
"@deepseek-ai/dsh-client-ui-schedule": ["./packages/client/ui-schedule/src"],
"@deepseek-ai/dsh-client-ui-schedule/client": ["./packages/client/ui-schedule/src/client/index.ts"],
"@deepseek-ai/dsh-client-ui-schedule/invariant": ["./packages/client/ui-schedule/src/invariant.ts"],
@@ -255,10 +249,6 @@
"@deepseek-ai/dsh-acp/invariant": ["./packages/acp/acp/src/invariant.ts"],
"@deepseek-ai/dsh-acp-app": ["./packages/bundle/acp-app/src"],
"@deepseek-ai/dsh-acp-app/invariant": ["./packages/bundle/acp-app/src/invariant.ts"],
- "@deepseek-ai/dsh-activity": ["./packages/activity/activity/src"],
- "@deepseek-ai/dsh-activity/invariant": ["./packages/activity/activity/src/invariant.ts"],
- "@deepseek-ai/dsh-activity-local": ["./packages/activity/activity-local/src"],
- "@deepseek-ai/dsh-activity-local/invariant": ["./packages/activity/activity-local/src/invariant.ts"],
"@deepseek-ai/dsh-agent": ["./packages/core/agent/src"],
"@deepseek-ai/dsh-agent-default-model": ["./packages/core/agent-default-model/src"],
"@deepseek-ai/dsh-agent-default-model/invariant": ["./packages/core/agent-default-model/src/invariant.ts"],
diff --git a/tsconfig.client.json b/tsconfig.client.json
index 2426473861..bb6735bbc7 100644
--- a/tsconfig.client.json
+++ b/tsconfig.client.json
@@ -57,7 +57,6 @@
{ "path": "./packages/typert/registry" },
{ "path": "./packages/api/gateway/tsconfig.client.json" },
{ "path": "./packages/api/session-controller/tsconfig.client.json" },
- { "path": "./packages/api/activity-controller/tsconfig.client.json" },
{ "path": "./packages/api/workspace-controller/tsconfig.client.json" },
{ "path": "./packages/api/remotes/tsconfig.client.json" },
{ "path": "./packages/extensions/cordis-client-runner" },
@@ -78,7 +77,7 @@
{ "path": "./packages/client/ui-skill" },
{ "path": "./packages/client/ui-reference" },
{ "path": "./packages/client/ui-subagent" },
- { "path": "./packages/client/ui-activity" },
+ { "path": "./packages/client/ui-jobs" },
{ "path": "./packages/experimental/client-ui-agent-team" },
{ "path": "./packages/client/ui-schedule" },
{ "path": "./packages/client/ui-directory-picker-browse" },
diff --git a/tsconfig.host.json b/tsconfig.host.json
index b9fac3cdff..4bbbda019b 100644
--- a/tsconfig.host.json
+++ b/tsconfig.host.json
@@ -81,7 +81,7 @@
"apps/web/tests/subagent-interrupt-ui.e2e.ts",
"apps/web/tests/sidebar-subagent-activity.e2e.ts",
"apps/web/tests/background-job-list.e2e.ts",
- "apps/web/tests/live-activity-stream.e2e.ts",
+ "apps/web/tests/live-job-stream.e2e.ts",
"apps/web/tests/bash-abort-row.e2e.ts",
"apps/web/tests/skill-tool-row.e2e.ts",
"apps/web/tests/turn-tail-actions.e2e.ts",
@@ -152,7 +152,6 @@
{ "path": "./packages/api/gateway/tsconfig.host.json" },
{ "path": "./packages/api/remotes/tsconfig.host.json" },
{ "path": "./packages/api/session-controller/tsconfig.host.json" },
- { "path": "./packages/api/activity-controller/tsconfig.host.json" },
{ "path": "./packages/api/settings-controller" },
{ "path": "./packages/api/workspace-controller/tsconfig.host.json" },
{ "path": "./packages/typert/loader" },
@@ -298,8 +297,6 @@
{ "path": "./packages/jobs/jobs" },
{ "path": "./packages/jobs/jobs-local" },
{ "path": "./packages/jobs/tool-jobs" },
- { "path": "./packages/activity/activity" },
- { "path": "./packages/activity/activity-local" },
{ "path": "./packages/experimental/agent-team" },
{ "path": "./packages/experimental/agent-team-profile" },
{ "path": "./packages/experimental/tool-agent-team" },