Merge remote-tracking branch 'origin/master' into feat/send-unify

# Conflicts:
#	examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl
#	examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl
#	packages/cordis/tool-cordis/src/api-catalog.ts
#	packages/pty/pty-local/tests/index.spec.ts
#	packages/session-query/session-query/tests/tracing.spec.ts
This commit is contained in:
Turtle
2026-07-23 22:41:45 +08:00
333 changed files with 10751 additions and 907 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

@@ -45,13 +45,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 }
}
@@ -86,10 +92,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 () => {
@@ -119,6 +126,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' })