fix(pty): close review lifecycle gaps

This commit is contained in:
Tianyi Cui
2026-07-22 22:37:20 +08:00
parent 1167ea71b7
commit 57a47b1fb3
47 changed files with 940 additions and 192 deletions

View File

@@ -4,7 +4,7 @@ The process-local background task registry (`ctx.tasks`). It gives long-running
## Service API
- `start(spec): TaskId` validates the control surface, spec, and exact live owner before calling the producer's `run()` once. A starter throw leaves nothing registered; successful return commits without another failable step.
- `start(spec): TaskId` validates the control surface, spec, exact live owner, and optional positive `outputLimitBytes` before calling the producer's `run()` once. A starter throw leaves nothing registered; successful return commits without another failable step.
- `get(id, caller?)` and `list(caller?)` return non-consuming snapshots. Listing includes only caller-owned and unowned tasks.
- `read(id, caller?)` consumes the single cursor for stream tasks and reads terminal output idempotently for final-output tasks.
- `kill(id, caller?, reason?)` invokes producer cancellation before changing status. A cancellation throw leaves the task running; success changes it to `stopping` and marks terminal delivery reported.
@@ -14,6 +14,8 @@ The process-local background task registry (`ctx.tasks`). It gives long-running
Owned access compares the task's `SessionId` with the caller's. Ids such as `bash-1` are predictable, so this fence is the boundary. Unowned tasks are open to callers and last until service disposal.
`outputLimitBytes` is producer-owned model-presentation policy carried unchanged into snapshots. A control surface applies it after adding status or notice metadata; the registry does not rewrite producer output or invent a default for producers that omit it.
## Lifecycle
Tasks belong to their owner and backend, not the producer tool fiber, so producer and surface reloads do not stop them. The first task for an owner attaches one awaited effect to the exact `Agent` scope. Owner disposal cancels that object's tasks, awaits producer quiescence, and removes their snapshots; reused agent or session ids cannot redirect an old cleanup.

View File

@@ -42,6 +42,7 @@ interface TrackedTask {
id: TaskId
kind: TaskKind
label: string
outputLimitBytes: number | undefined
/** Exact lifecycle owner; session-id authorization is derived from it. */
owner: Agent | undefined
cancel: (reason?: string) => void
@@ -104,6 +105,10 @@ export class TaskService extends Service {
}
if (spec.kind.length === 0) throw new Error('invalid task kind: expected a non-empty string')
if (spec.label.length === 0) throw new Error('invalid task label: expected a non-empty string')
if (spec.outputLimitBytes !== undefined
&& (!Number.isSafeInteger(spec.outputLimitBytes) || spec.outputLimitBytes <= 0)) {
throw new Error(`invalid outputLimitBytes: expected a positive safe integer, got ${JSON.stringify(spec.outputLimitBytes)}`)
}
if (spec.owner !== undefined) this.ensureOwnerCleanup(spec.owner)
const hooks = spec.run()
@@ -117,6 +122,7 @@ export class TaskService extends Service {
id,
kind: spec.kind,
label: spec.label,
outputLimitBytes: spec.outputLimitBytes,
owner: spec.owner,
cancel: hooks.cancel.bind(hooks),
readOutput: hooks.readOutput?.bind(hooks),
@@ -329,6 +335,7 @@ export class TaskService extends Service {
id: task.id,
kind: task.kind,
label: task.label,
...task.outputLimitBytes !== undefined ? { outputLimitBytes: task.outputLimitBytes } : {},
...ownerSession !== undefined ? { ownerSession } : {},
status: task.status,
...task.detail !== undefined ? { detail: task.detail } : {},

View File

@@ -61,6 +61,11 @@ export interface TaskStart {
kind: TaskKind
/** One-line model-facing label (the command; the delegation description). */
label: string
/**
* Optional UTF-8 byte cap for each complete model-facing completion notice or
* output read, including control-surface status metadata.
*/
outputLimitBytes?: number
/**
* Owning live agent. Access is fenced by its session id, and agent disposal
* cancels and awaits the task. The instance must be the one currently
@@ -109,6 +114,8 @@ export interface TaskSnapshot {
kind: TaskKind
/** The producer-supplied one-line label. */
label: string
/** Producer-owned cap for complete model-facing notices and output reads. */
outputLimitBytes?: number
/**
* Owner session id used for authorization and correlation; absent for
* unowned tasks. Completion listeners receive the exact {@link Agent}

View File

@@ -44,13 +44,19 @@ function producer(overrides: Partial<Omit<TaskStart, 'run'> & TaskHooks> = {}) {
let settle!: (outcome: TaskOutcome) => void
let reject!: (error: unknown) => void
const cancels: (string | undefined)[] = []
const { kind = 'bash', label = 'sleep 60', owner, ...hookOverrides } = overrides
const { kind = 'bash', label = 'sleep 60', owner, outputLimitBytes, ...hookOverrides } = overrides
const hooks: TaskHooks = {
cancel(reason) { cancels.push(reason) },
done: new Promise<TaskOutcome>((res, rej) => { settle = res; reject = rej }),
...hookOverrides,
}
const spec: TaskStart = { kind, label, ...owner !== undefined ? { owner } : {}, run: () => hooks }
const spec: TaskStart = {
kind,
label,
...owner !== undefined ? { owner } : {},
...outputLimitBytes !== undefined ? { outputLimitBytes } : {},
run: () => hooks,
}
return { spec, settle, reject, cancels }
}
@@ -85,10 +91,11 @@ describe('TaskService.start', () => {
.toThrow('background tasks unavailable: no control surface is attached (load @deepseek-ai/dsh-tool-tasks)')
})
it('rejects an empty kind and an empty label', async () => {
it('rejects an empty kind, empty label, and invalid output limit', async () => {
const ctx = await harness()
expect(() => ctx.tasks.start(producer({ kind: '' as TaskKind }).spec)).toThrow('invalid task kind')
expect(() => ctx.tasks.start(producer({ label: '' }).spec)).toThrow('invalid task label')
expect(() => ctx.tasks.start(producer({ outputLimitBytes: 0 }).spec)).toThrow('outputLimitBytes')
})
it('issues kind-prefixed ids from per-kind counters', async () => {
@@ -118,6 +125,16 @@ describe('TaskService reads and settlement', () => {
expect(read.snapshot.finishedAt).toBeTypeOf('number')
})
it('projects a producer-owned model output limit into reads and snapshots', async () => {
const ctx = await harness()
const p = producer({ outputLimitBytes: 64, readOutput: () => 'delta' })
const id = ctx.tasks.start(p.spec)
expect(ctx.tasks.read(id)).toMatchObject({
text: 'delta', snapshot: { outputLimitBytes: 64 },
})
expect(ctx.tasks.get(id)).toMatchObject({ outputLimitBytes: 64 })
})
it('final-output kinds read empty while live, the outcome output idempotently once settled', async () => {
const ctx = await harness()
const p = producer({ kind: 'subagent', label: 'research task' })

View File

@@ -10,6 +10,8 @@ The model-facing control surface for `ctx.tasks`: three kind-independent tools,
All three use generic ACP cards: `read` for output and list, `execute` for kill.
When a producer supplies `outputLimitBytes`, `task_output`, terminal `task_kill`, and completion notices cap the complete UTF-8 result after adding status or notice text. The output tail and control suffix are retained when they fit; an existing producer truncation marker is reused rather than duplicated. Producers that omit the field retain the existing unbounded control-surface behavior.
## Completion notices
An unreported completion injects `background task <id> (<kind>: <label>) finished [status: ...]. Read its output with task_output.` into the exact owner's session. Injection is durable context for the next request, not a wake-up. A kill or terminal read/wait marks delivery reported and suppresses the redundant notice; owner-disposal races are contained.
@@ -67,7 +69,7 @@ Reads return output or `(no new output)` followed by `[status: <status>]` and op
#### Token effect
Results and notices remain in parent history until compaction. Stream reads do not repeat consumed output.
Results and notices remain in parent history until compaction. Stream reads do not repeat consumed output; a producer-supplied `outputLimitBytes` bounds each complete read or notice.
#### KV Cache effect

View File

@@ -26,21 +26,23 @@
"src"
],
"license": "BSD-3-Clause",
"dependencies": {
"schemastery": "^3.18.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-retention": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
"@deepseek-ai/dsh-tasks": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
"dependencies": {
"schemastery": "^3.18.0"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-retention": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tasks": "workspace:^",

View File

@@ -8,6 +8,7 @@
import type { Context } from 'cordis'
import z from 'schemastery'
import { TextRetainer } from '@deepseek-ai/dsh-retention'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { GenericCallView } from '@deepseek-ai/dsh-tools'
import { TaskId } from '@deepseek-ai/dsh-tasks'
@@ -41,6 +42,28 @@ export function statusLine(snapshot: TaskSnapshot): string {
: `[status: ${snapshot.status}]`
}
const encoder = new TextEncoder()
function retainTail(text: string, maxBytes: number): string {
const retainer = new TextRetainer({ kind: 'tail', maxBytes })
retainer.push(text)
return retainer.finish().text
}
function fitWithSuffix(
content: string,
suffix: string,
maxBytes: number | undefined,
omitted: string,
): string {
const complete = `${content}${suffix}`
if (maxBytes === undefined || encoder.encode(complete).byteLength <= maxBytes) return complete
const fixed = `${content.endsWith(omitted.trimStart()) ? '' : omitted}${suffix}`
const fixedBytes = encoder.encode(fixed).byteLength
if (fixedBytes >= maxBytes) return retainTail(fixed, maxBytes)
return `${retainTail(content, maxBytes - fixedBytes)}${fixed}`
}
/** Validate the non-empty constraint that SchemaSpec cannot express. */
function validateTaskId(value: string): TaskId {
if (value.length === 0) {
@@ -75,8 +98,13 @@ export function apply(ctx: Context, config: Config): void {
ctx.tasks.onTaskDone((snapshot, owner) => {
if (snapshot.reported || owner === undefined) return
try {
const prefix = `background task ${snapshot.id} (${snapshot.kind}: ${snapshot.label})`
const suffix = ` finished ${statusLine(snapshot)}. Read its output with task_output.`
owner.inject(
[{ type: 'text', text: `background task ${snapshot.id} (${snapshot.kind}: ${snapshot.label}) finished ${statusLine(snapshot)}. Read its output with task_output.` }],
[{
type: 'text',
text: fitWithSuffix(prefix, suffix, snapshot.outputLimitBytes, '\n[notice truncated]'),
}],
{ source: { kind: 'plugin', plugin: 'tool-tasks' } },
)
} catch (error: unknown) {
@@ -106,8 +134,16 @@ export function apply(ctx: Context, config: Config): void {
}
const read = ctx.tasks.read(id, exec.agent)
const body = read.text.length > 0 ? read.text : '(no new output)'
const separator = body.endsWith('\n') ? '' : '\n'
return [{ type: 'text', text: `${body}${separator}${statusLine(read.snapshot)}` }]
const content = body.endsWith('\n') ? body.slice(0, -1) : body
return [{
type: 'text',
text: fitWithSuffix(
content,
`\n${statusLine(read.snapshot)}`,
read.snapshot.outputLimitBytes,
'\n[output truncated]',
),
}]
},
presentCall: args => presentTaskCall(`Read output from background task ${args.task_id}`, 'read', args.task_id),
}))
@@ -139,7 +175,15 @@ export function apply(ctx: Context, config: Config): void {
if (result === 'already-finished') {
// A snapshot describes terminal state without consuming pending output.
const snapshot = ctx.tasks.get(id, exec.agent)
return Promise.resolve([{ type: 'text', text: `task ${id} had already finished ${statusLine(snapshot)}` }])
return Promise.resolve([{
type: 'text',
text: fitWithSuffix(
`task ${id} had already finished`,
` ${statusLine(snapshot)}`,
snapshot.outputLimitBytes,
'\n[notice truncated]',
),
}])
}
return Promise.resolve([{ type: 'text', text: `requested cancellation of task ${id}` }])
},

View File

@@ -52,13 +52,19 @@ function detachAgent(agent: Agent): void {
function producer(overrides: Partial<Omit<TaskStart, 'run'> & TaskHooks> = {}) {
let settle!: (outcome: TaskOutcome) => void
const cancels: (string | undefined)[] = []
const { kind = 'bash', label = 'sleep 60', owner, ...hookOverrides } = overrides
const { kind = 'bash', label = 'sleep 60', owner, outputLimitBytes, ...hookOverrides } = overrides
const hooks: TaskHooks = {
cancel(reason) { cancels.push(reason) },
done: new Promise<TaskOutcome>((res) => { settle = res }),
...hookOverrides,
}
const spec: TaskStart = { kind, label, ...owner !== undefined ? { owner } : {}, run: () => hooks }
const spec: TaskStart = {
kind,
label,
...owner !== undefined ? { owner } : {},
...outputLimitBytes !== undefined ? { outputLimitBytes } : {},
run: () => hooks,
}
return { spec, settle, cancels }
}
@@ -131,6 +137,18 @@ describe('task_output', () => {
expect(text(await call(ctx, 'task_output', { task_id: 'subagent-1' }))).toBe('the answer\n[status: completed, completed]')
})
it('applies a producer limit to the complete body and status result', async () => {
const { ctx } = await setup()
ctx.tasks.start(producer({
outputLimitBytes: 48,
readOutput: () => '界'.repeat(100),
}).spec)
const output = text(await call(ctx, 'task_output', { task_id: 'bash-1' }))
expect(Buffer.byteLength(output)).toBeLessThanOrEqual(48)
expect(output).toContain('[status: running]')
})
it('wait: true blocks until settlement and reports the terminal state', async () => {
const { ctx } = await setup()
const p = producer({ kind: 'subagent', label: 'research' })

View File

@@ -17,6 +17,9 @@
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../util/retention"
},
{
"path": "../../core/agent"
},