Files
deepseek-harness/packages/api/job-controller/src/index.ts
T
Yichen Jiang 0659ded55b refactor(job-controller): drop the subagent ownership fence from job.kill
The registry's owner fence is the only access rule for a human kill: a job
is killable by the session whose list carries it, and a child session's own
jobs are no exception. The fence had pulled two implementation helpers out
of the Session Controller's package entry (plus a safe-host-export exception
for them) so the job controller could import them; both go, and the job
controller no longer depends on the agent registry or the Session Controller.
2026-09-21 16:34:15 +08:00

131 lines
5.6 KiB
TypeScript

/**
* Host job Remote owner: streams the background-job roster one session can
* see and one job's retained output to browsers over the generated `job`
* namespace, and stops a job on a human's behalf. The streams are
* projections of `ctx.jobs`; the model's consuming cursor and notice state
* never observe them, and a human kill is not the model's own, so the
* completion notice still reaches the owning agent.
* @module @deepseek-ai/dsh-api-job-controller
*/
import { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import type {} from '@deepseek-ai/dsh-jobs'
import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
import { observeJobOutput } from './observe.ts'
import { streamJobRows } from './rows.ts'
import type { JobKillRequest, JobKillValue, JobFollowFrame, JobFollowRequest, JobListFrame, JobListRequest } from './types.ts'
export type * from './types.ts'
declare module '@deepseek-ai/cordis' {
interface Context {
/** Host job Remote namespace owner. */
jobController: JobController
}
}
/** Default coalescing window between reads, in milliseconds. */
const DEFAULT_OBSERVE_FLUSH_MS = 100
/** Default soft byte budget per output frame. */
const DEFAULT_OBSERVE_MAX_FRAME_BYTES = 64 * 1024
/** Job Controller deployment policy. */
export interface Config {
/** Coalescing window after a registry commit before the next rows or output read, in milliseconds (default 100). */
readonly observeFlushMs?: number
/** Soft byte budget per observation output frame (default 65536); one larger chunk ships whole. */
readonly observeMaxFrameBytes?: number
}
/** Host service backing the generated `ctx.remote.job` namespace. */
export class JobController extends TypertRemoteService {
static inject = ['jobs', 'typert']
static Config: z<Config> = z.object({
observeFlushMs: z.natural().min(1).default(DEFAULT_OBSERVE_FLUSH_MS),
observeMaxFrameBytes: z.natural().min(1).default(DEFAULT_OBSERVE_MAX_FRAME_BYTES),
})
private readonly observeFlushMs: number
private readonly observeMaxFrameBytes: number
/**
* @param ctx - Host context carrying the live Agent registry and the job registry.
* @param config - observation cadence and framing policy.
*/
constructor(ctx: Context, config: Config) {
super(ctx, 'jobController', { namespace: 'job' })
// schemastery (the exported Config schema) has already filled the defaulted
// fields; the assertion records that resolution, not a hidden fallback.
const resolved = config as Required<Config>
this.observeFlushMs = resolved.observeFlushMs
this.observeMaxFrameBytes = resolved.observeMaxFrameBytes
}
/**
* Stream the jobs one session can see — its own plus every unowned job —
* as whole-set frames: one on open, then one after each coalesced burst of
* lifecycle commits. The stream has no natural end; the carrier closes it.
* @param request - the session whose visible set to mirror.
* @param signal - cancellation owned by the Remote stream carrier.
* @returns the roster frames.
*/
@Remote({ mode: 'stream' })
list(request: JobListRequest, signal: AbortSignal): AsyncIterable<JobListFrame> {
return streamJobRows(this.ctx.jobs, request, { flushMs: this.observeFlushMs }, signal)
}
/**
* Stream one job's retained output from an absolute byte offset, then its
* terminal projection once settled and drained. Non-consuming: the
* model-facing cursor and notice state never observe these reads. The
* request's session is the fenced read's caller; the registry rejects a
* job the session cannot see and an unknown job.
* @param request - target job, owning session, and optional resume offset.
* @param signal - cancellation owned by the Remote stream carrier.
* @returns anchor, coalesced output frames, and the terminal status.
*/
@Remote({ mode: 'stream' })
follow(request: JobFollowRequest, signal: AbortSignal): AsyncIterable<JobFollowFrame> {
return observeJobOutput(this.ctx.jobs, request, {
flushMs: this.observeFlushMs,
maxFrameBytes: this.observeMaxFrameBytes,
}, signal)
}
/**
* Kill one background job on a human's behalf. The request's session is
* the fenced read's caller, so the job must be one that session can see:
* the registry's owner fence is the only access rule, and a child session's
* own jobs are killable from its list like any other. The kill records
* `cancelled by the user` as its reason; it is not one the model requested,
* so the owning agent still receives the completion notice, and a shell
* tool waiting on that job reads the reason in its own result.
* @param request - Session whose job list carries the job, and the job id.
* @returns the registry's admission of the kill request.
*/
@Remote('kill')
kill(request: JobKillRequest): JobKillValue {
const jobs = this.ctx.jobs
try {
jobs.get(request.jobId, request.sessionId)
} catch (error) {
// `unknown job` and `belongs to another session` both mean this session's
// list no longer carries a killable row; the client renders one story.
throw new RemoteError('job/not-found', String(error), {
sessionId: request.sessionId,
jobId: request.jobId,
})
}
// Same synchronous span as the lookup, so nothing can remove the job in
// between — and a producer-cancel throw propagates per the registry
// contract (job state unchanged) instead of masquerading as job-not-found.
const outcome = jobs.kill(request.jobId, request.sessionId, 'cancelled by the user')
return { outcome }
}
}
export default JobController