refactor(jobs): move the human kill to the job namespace

`session.killJob` becomes `job.kill` on `dsh-api-job-controller`, beside
`job.observe`, so the Session Controller carries only session control and
the roster while every per-job Remote lives on the job namespace. The
client `ctx.jobOutput` gains `kill(sessionId, jobId)`; `dsh-client-ui-jobs`
routes its stop control through it and no longer requires `sessions`.
Unknown or foreign jobs reject with `job/not-found`; the controller
requires `ctx.jobs` to load, so `jobs-unavailable` disappears. The two
subagent ownership-fence helpers are classified duplicate-install-safe
Host exports of the Session Controller for the cross-controller import.
This commit is contained in:
Yichen Jiang committed 2026-09-02 23:18:08 +08:00
1 parent 121db081a4
commit e8469d6f6c
50 files changed
+282 -324

No files matched your search

@@ -107,14 +107,6 @@ export interface ISession {
* @returns acceptance, or the business error.
*/
cancel(): Promise<RemoteResult<{ accepted: true }>>
/**
* Kill one background job from this session's task list. The owning agent
* still receives the standard completion notice, because a human kill has no
* other model-visible channel; row state converges through the jobs frames.
* @param jobId - the job row's registry id.
* @returns the registry's admission, or the business/transport error.
*/
killJob(jobId: string): Promise<RemoteResult<{ outcome: 'requested' | 'already-finished' }>>
/**
* Rename this session (explicit user title; pins it against automatic
* regeneration).
@@ -4,7 +4,6 @@ import type { Context } from '@deepseek-ai/cordis'
import { randomUUID } from '@deepseek-ai/dsh-util-crypto'
import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client'
import type { JobId } from '@deepseek-ai/dsh-jobs/brand'
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
import { SessionLogOffset, SessionSeq, type SessionId } from '@deepseek-ai/dsh-session/types'
import { SessionEventStream } from '../transport.ts'
@@ -326,19 +325,6 @@ export class Session implements SessionFace {
return result
}
/**
* Kill one background job from this session's task list. Pure RPC passthrough:
* row state converges through the jobs control frames, and the caller (the
* task-list control) owns error presentation, so no snapshot state changes here.
* @param jobId - the job row's registry id.
* @returns the registry's admission of the kill request.
*/
async killJob(jobId: string): Promise<RemoteResult<{ outcome: 'requested' | 'already-finished' }>> {
// The brand is nominal typing only; a client program stamps it at the
// wire boundary rather than value-importing the host-side constructor.
return this.remote.session.killJob({ sessionId: this.sessionId, jobId: jobId as JobId })
}
/**
* Rename: contract session.rename 1:1. On success settle the 'title'
* projection cell from the response's `{title, seq}` under the store's
@@ -37,8 +37,6 @@ import type {
SessionCreateValue,
SessionForkRequest,
SessionForkValue,
SessionKillJobRequest,
SessionKillJobValue,
SessionPromptRequest,
SessionPromptValue,
SessionRenameRequest,
@@ -450,46 +448,6 @@ export class SessionCommandController {
return { accepted: true }
}
/**
* Kill one background job on a human's behalf. The kill leaves the terminal
* report unclaimed (`reported: false`), so the owning agent still receives
* the standard completion notice — a human cancellation has no other
* model-visible channel. The Agent lookup is live-only: a running job's
* owner is alive by the registry's ownership contract, and listing never
* revives a Session, so neither does killing from its list. The service-wide
* subagent ownership fence applies exactly as it does to `cancel`.
* @param request - Session whose task list carries the job, and the job id.
* @returns the registry's admission of the kill request.
*/
killJob(request: SessionKillJobRequest): SessionKillJobValue {
const jobs = this.ctx.get('jobs')
if (jobs === undefined) {
throw new RemoteError('session/jobs-unavailable', 'background jobs are not available in this composition', {})
}
const agent = this.ctx.agents.get(request.sessionId)
if (agent !== undefined && hasApiSessionSubagentOwner(this.ctx, agent.session, agent)) {
throw apiSessionSubagentOwnershipError(request.sessionId)
}
try {
jobs.get(request.jobId, agent)
} 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('session/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 now propagates per the registry
// contract (job state unchanged) instead of masquerading as job-not-found.
const outcome = jobs.kill(request.jobId, agent, {
reason: 'cancelled by the user',
reported: false,
})
return { outcome }
}
private async resolveAgent(sessionId: SessionId): Promise<Agent> {
const found = await this.agents.resolveAgent(sessionId)
if ('error' in found) throw found.error
+1 -14
View File
@@ -38,8 +38,6 @@ import type {
SessionFollowRequest,
SessionForkRequest,
SessionForkValue,
SessionKillJobRequest,
SessionKillJobValue,
SessionListRequest,
SessionListValue,
SessionOpenWorkspacePathRequest,
@@ -59,7 +57,7 @@ import type {
} from './types.ts'
export type * from './types.ts'
export { ApiSessionNotFound } from './agent.ts'
export { ApiSessionNotFound, apiSessionSubagentOwnershipError, hasApiSessionSubagentOwner } from './agent.ts'
export { SessionFileReferences } from './file-references.ts'
export { SessionSkillCatalog } from './skill-catalog.ts'
@@ -369,17 +367,6 @@ export class SessionController extends TypertRemoteService {
return this.commands.cancel(request)
}
/**
* Kill one background job on a human's behalf, leaving the terminal report
* unclaimed so the owning agent still receives the completion notice.
* @param request - Session whose task list carries the job, and the job id.
* @returns the registry's admission of the kill request.
*/
@Remote('killJob')
killJob(request: SessionKillJobRequest): SessionKillJobValue {
return this.commands.killJob(request)
}
/**
* Read one cold-safe, message-aligned Session history page.
* @param request - durable address, backward cursor, and page budget.
@@ -194,8 +194,6 @@ declare module '@deepseek-ai/dsh-typert-protocol' {
'session/attachment-invalid': { readonly reason: string }
'session/queue-item-not-found': { readonly itemId: MessageId }
'session/steer-unavailable': { readonly itemId: MessageId }
'session/job-not-found': { readonly sessionId: SessionId; readonly jobId: JobId }
'session/jobs-unavailable': Record<never, never>
'session/title-invalid': { readonly sessionId: SessionId }
'session/fork-unavailable': { readonly sessionId: SessionId }
'subagent/not-found': {
@@ -349,17 +347,6 @@ export interface SessionCancelValue {
readonly accepted: true
}
/** Human-initiated cancellation of one background job visible to a Session. */
export interface SessionKillJobRequest {
readonly sessionId: SessionId
readonly jobId: JobId
}
/** Receipt after the registry accepted the human kill request. */
export interface SessionKillJobValue {
readonly outcome: 'requested' | 'already-finished'
}
/** Request to open one path prepared by a Session-aware caller on the Host desktop. */
export interface SessionOpenWorkspacePathRequest {
/** Path after best-effort Session workspace resolution, in Host filesystem syntax. */