- Store runtime selection as { provider?, model? } layers and resolve them per
field (conversation -> project -> global); drop the legacy auto provider.
- Migrate stored selections (database v49) and clear deleted profile ids.
- Re-resolve selections in Main per request and at queue dispatch.
- Add ProjectRuntimeSelector and a two-column RuntimeModelPicker; manage
link opens model connections; restore per-runtime default model tabs.
- Stop duplicating trusted instructions and history across OpenCode,
Continue and DeepSeek Harness runtimes.
- Document the layered design, global provider removal plan and planned
purpose-specific models.
666 lines
18 KiB
TypeScript
666 lines
18 KiB
TypeScript
import type {
|
|
AgentQuestionAnswer,
|
|
AgentRuntimeStatus,
|
|
RuntimeConversationCompactInput,
|
|
RuntimeNativeSnapshot
|
|
} from '../../shared/contracts'
|
|
import type {
|
|
AgentExecutionRequest,
|
|
AgentRuntime,
|
|
RuntimeAuthorizer,
|
|
RuntimeConversationCompactOutcome,
|
|
RuntimeEvent
|
|
} from './runtime'
|
|
import type {
|
|
DesktopDiagnosticFailureObserver
|
|
} from '../desktop-diagnostics'
|
|
|
|
type RuntimeSlot = {
|
|
runtime: AgentRuntime
|
|
activeRequests: number
|
|
retiring: boolean
|
|
drainable: boolean
|
|
drainStarted?: Promise<void>
|
|
drainError?: Error
|
|
disposal?: Promise<void>
|
|
disposeStarted?: Promise<void>
|
|
resolveDisposal?: () => void
|
|
}
|
|
|
|
export const MAX_DRAINING_RUNTIME_GENERATIONS = 2
|
|
|
|
export type RuntimeReplacementStatus = {
|
|
drainingGenerations: number
|
|
failedDrainingGenerations: number
|
|
pendingReplacements: number
|
|
maximumDrainingGenerations: number
|
|
saturated: boolean
|
|
}
|
|
|
|
export class RuntimeReplacementCapacityError extends Error {
|
|
readonly code = 'runtime-drain-capacity'
|
|
|
|
constructor(
|
|
readonly occupiedGenerations: number,
|
|
readonly maximumGenerations: number
|
|
) {
|
|
super(
|
|
`Runtime replacement is unavailable while previous generations are draining (${occupiedGenerations}/${maximumGenerations})`
|
|
)
|
|
this.name = 'RuntimeReplacementCapacityError'
|
|
}
|
|
}
|
|
|
|
export interface DrainableAgentRuntime extends AgentRuntime {
|
|
/** Stop accepting new sessions while existing session ownership remains. */
|
|
beginDrain(): Promise<void>
|
|
/** Resolve only after all owned sessions are released or interrupted. */
|
|
waitForDrain(): Promise<void>
|
|
/** Explicit bounded-shutdown escalation; never used by replacement. */
|
|
forceShutdown(): Promise<void>
|
|
}
|
|
|
|
function isDrainableRuntime(
|
|
runtime: AgentRuntime
|
|
): runtime is DrainableAgentRuntime {
|
|
const candidate = runtime as Partial<DrainableAgentRuntime>
|
|
return (
|
|
typeof candidate.beginDrain === 'function' &&
|
|
typeof candidate.waitForDrain === 'function' &&
|
|
typeof candidate.forceShutdown === 'function'
|
|
)
|
|
}
|
|
|
|
export class AgentRuntimeController implements AgentRuntime {
|
|
private current: RuntimeSlot
|
|
private readonly retired = new Set<RuntimeSlot>()
|
|
private replacementQueue: Promise<void> = Promise.resolve()
|
|
private pendingReplacements = 0
|
|
private closing = false
|
|
private retirement?: Promise<void>
|
|
private disposal?: Promise<void>
|
|
private readonly ownedConversationIds = new Set<string>()
|
|
private readonly questionOwners = new Map<string, { slot: RuntimeSlot; requestId: string }>()
|
|
|
|
constructor(
|
|
runtime: AgentRuntime,
|
|
private readonly shutdownGraceMs = 2_000,
|
|
private readonly observeFailure?:
|
|
DesktopDiagnosticFailureObserver
|
|
) {
|
|
this.current = {
|
|
runtime,
|
|
activeRequests: 0,
|
|
retiring: false,
|
|
drainable: isDrainableRuntime(runtime)
|
|
}
|
|
}
|
|
|
|
get requiresToolApproval(): boolean {
|
|
return this.current.runtime.requiresToolApproval
|
|
}
|
|
|
|
get runtimeId(): AgentRuntimeStatus['id'] | undefined {
|
|
return this.current.runtime.runtimeId
|
|
}
|
|
|
|
get supportsToolExecution(): boolean {
|
|
return this.current.runtime.supportsToolExecution
|
|
}
|
|
|
|
get supportsScopedDataTools(): boolean {
|
|
return this.current.runtime.supportsScopedDataTools !== false
|
|
}
|
|
|
|
get consumesTrustedInstructions(): boolean {
|
|
return this.current.runtime.consumesTrustedInstructions === true
|
|
}
|
|
|
|
get capability(): AgentRuntime['capability'] {
|
|
return this.current.runtime.capability
|
|
}
|
|
|
|
get replacementStatus(): RuntimeReplacementStatus {
|
|
const drainingGenerations = [...this.retired].filter(
|
|
(slot) => slot.drainable
|
|
).length
|
|
const failedDrainingGenerations = [...this.retired].filter(
|
|
(slot) =>
|
|
slot.drainable &&
|
|
slot.drainError !== undefined
|
|
).length
|
|
const occupiedGenerations =
|
|
drainingGenerations + this.pendingReplacements
|
|
return {
|
|
drainingGenerations,
|
|
failedDrainingGenerations,
|
|
pendingReplacements: this.pendingReplacements,
|
|
maximumDrainingGenerations: MAX_DRAINING_RUNTIME_GENERATIONS,
|
|
saturated:
|
|
occupiedGenerations >= MAX_DRAINING_RUNTIME_GENERATIONS
|
|
}
|
|
}
|
|
|
|
get activeRequestCount(): number {
|
|
return [...new Set([this.current, ...this.retired])].reduce(
|
|
(total, slot) => total + slot.activeRequests,
|
|
0
|
|
)
|
|
}
|
|
|
|
get ownedConversationCount(): number {
|
|
return this.ownedConversationIds.size
|
|
}
|
|
|
|
get sharedProcess(): boolean {
|
|
return this.current.runtime.sharedProcess === true
|
|
}
|
|
|
|
get canRetire(): boolean {
|
|
return (
|
|
!this.closing &&
|
|
this.activeRequestCount === 0 &&
|
|
(this.sharedProcess || this.ownedConversationIds.size === 0)
|
|
)
|
|
}
|
|
|
|
replace(next: AgentRuntime): Promise<void> {
|
|
if (this.closing) {
|
|
return this.disposeRejectedRuntime(next).then(() => {
|
|
throw new Error('Agent Runtime 正在关闭')
|
|
})
|
|
}
|
|
const status = this.replacementStatus
|
|
const occupiedGenerations =
|
|
status.drainingGenerations + status.pendingReplacements
|
|
if (occupiedGenerations >= MAX_DRAINING_RUNTIME_GENERATIONS) {
|
|
return this.disposeRejectedRuntime(next).then(() => {
|
|
throw new RuntimeReplacementCapacityError(
|
|
occupiedGenerations,
|
|
MAX_DRAINING_RUNTIME_GENERATIONS
|
|
)
|
|
})
|
|
}
|
|
this.pendingReplacements += 1
|
|
const operation = this.replacementQueue
|
|
.then(() => this.performReplace(next))
|
|
.finally(() => {
|
|
this.pendingReplacements -= 1
|
|
})
|
|
this.replacementQueue = operation.catch(() => undefined)
|
|
return operation
|
|
}
|
|
|
|
private async performReplace(next: AgentRuntime): Promise<void> {
|
|
const previous = this.current
|
|
this.current = {
|
|
runtime: next,
|
|
activeRequests: 0,
|
|
retiring: false,
|
|
drainable: isDrainableRuntime(next)
|
|
}
|
|
const disposal = this.retireSlot(previous)
|
|
if (previous.drainable) {
|
|
await previous.drainStarted
|
|
return
|
|
}
|
|
await waitWithin(disposal, this.shutdownGraceMs)
|
|
}
|
|
|
|
private async disposeRejectedRuntime(runtime: AgentRuntime): Promise<void> {
|
|
await waitWithin(
|
|
runtime.dispose().catch(() => undefined),
|
|
this.shutdownGraceMs
|
|
)
|
|
}
|
|
|
|
async getStatus(): Promise<AgentRuntimeStatus> {
|
|
return this.probeStatus(
|
|
'status',
|
|
(runtime) => runtime.getStatus()
|
|
)
|
|
}
|
|
|
|
async testConnection(): Promise<AgentRuntimeStatus> {
|
|
return this.probeStatus(
|
|
'connection-test',
|
|
(runtime) =>
|
|
runtime.testConnection?.() ?? runtime.getStatus()
|
|
)
|
|
}
|
|
|
|
async getNativeSnapshot(): Promise<RuntimeNativeSnapshot> {
|
|
return this.invoke('native-snapshot', (runtime) => {
|
|
if (!runtime.getNativeSnapshot) {
|
|
throw new Error('当前 Runtime 不支持原生能力清单')
|
|
}
|
|
return runtime.getNativeSnapshot()
|
|
})
|
|
}
|
|
|
|
async compactConversation(
|
|
request: RuntimeConversationCompactInput,
|
|
signal: AbortSignal
|
|
): Promise<RuntimeConversationCompactOutcome> {
|
|
this.ownedConversationIds.add(request.conversationId)
|
|
return this.invoke(
|
|
'compact',
|
|
(runtime) => {
|
|
if (!runtime.compactConversation) {
|
|
throw new Error('当前 Runtime 不支持手动压缩')
|
|
}
|
|
return runtime.compactConversation(request, signal)
|
|
},
|
|
signal
|
|
)
|
|
}
|
|
|
|
private async probeStatus(
|
|
stage: string,
|
|
operation: (runtime: AgentRuntime) => Promise<AgentRuntimeStatus>
|
|
): Promise<AgentRuntimeStatus> {
|
|
const status = await this.invoke(stage, operation)
|
|
return {
|
|
...status,
|
|
supportsToolExecution: this.current.runtime.supportsToolExecution
|
|
}
|
|
}
|
|
|
|
private async invoke<T>(
|
|
stage: string,
|
|
operation: (runtime: AgentRuntime) => Promise<T>,
|
|
signal?: AbortSignal
|
|
): Promise<T> {
|
|
if (this.closing) {
|
|
throw new Error('Agent Runtime 正在关闭')
|
|
}
|
|
const slot = this.current
|
|
slot.activeRequests += 1
|
|
try {
|
|
const status = await operation(slot.runtime)
|
|
if (slot !== this.current && !(slot.runtime.sharedProcess && stage === 'compact')) {
|
|
throw new Error('Runtime 已切换,请重试')
|
|
}
|
|
return status
|
|
} catch (error) {
|
|
if (
|
|
slot === this.current &&
|
|
!signal?.aborted &&
|
|
!isAbortError(error)
|
|
) {
|
|
this.reportFailure(stage, 'runtime.operation.failed', error)
|
|
}
|
|
throw error
|
|
} finally {
|
|
slot.activeRequests -= 1
|
|
if (
|
|
slot.retiring &&
|
|
!slot.drainable &&
|
|
slot.activeRequests === 0
|
|
) {
|
|
await this.disposeSlot(slot)
|
|
}
|
|
}
|
|
}
|
|
|
|
async *run(
|
|
request: AgentExecutionRequest,
|
|
signal: AbortSignal,
|
|
authorize?: RuntimeAuthorizer
|
|
): AsyncGenerator<RuntimeEvent, void, void> {
|
|
if (this.closing) {
|
|
throw new Error('Agent Runtime 正在关闭')
|
|
}
|
|
const slot = this.current
|
|
this.ownedConversationIds.add(request.conversationId)
|
|
const toolsAllowed = request.workMode === 'execute'
|
|
let toolDenied = false
|
|
const effectiveAuthorize: RuntimeAuthorizer | undefined = toolsAllowed
|
|
? authorize === undefined
|
|
? undefined
|
|
: async (authorizationRequest) => {
|
|
const decision = await authorize(authorizationRequest)
|
|
if (decision === 'deny') {
|
|
toolDenied = true
|
|
}
|
|
return decision
|
|
}
|
|
: async () => {
|
|
toolDenied = true
|
|
return 'deny'
|
|
}
|
|
slot.activeRequests += 1
|
|
try {
|
|
if (toolsAllowed && !slot.runtime.supportsToolExecution) {
|
|
throw new Error('当前 Runtime 不支持工具执行,请切换到 OpenCode 或 Continue')
|
|
}
|
|
if (
|
|
toolsAllowed &&
|
|
slot.runtime.requiresToolApproval &&
|
|
effectiveAuthorize
|
|
) {
|
|
const decision = await effectiveAuthorize({
|
|
scopeKey: 'runtime:whole-run',
|
|
title: '允许 Agent 使用工作区工具?',
|
|
description:
|
|
'该 Runtime 尚不能报告单个工具调用,可能读取或修改工作区文件并执行命令。',
|
|
allowPermanent: false
|
|
})
|
|
if (decision === 'deny') {
|
|
throw new Error('用户拒绝了 Agent 工具执行')
|
|
}
|
|
}
|
|
try {
|
|
for await (const event of slot.runtime.run(
|
|
request,
|
|
signal,
|
|
effectiveAuthorize
|
|
)) {
|
|
if (slot !== this.current && !slot.drainable && !slot.runtime.sharedProcess) {
|
|
throw new Error('Runtime 已切换,当前请求已中断')
|
|
}
|
|
toolDenied = false
|
|
if (event.type === 'question') {
|
|
this.questionOwners.set(event.questionId, { slot, requestId: request.requestId })
|
|
}
|
|
yield event
|
|
}
|
|
if (slot !== this.current && !slot.drainable && !slot.runtime.sharedProcess) {
|
|
throw new Error('Runtime 已切换,当前请求已中断')
|
|
}
|
|
} catch (error) {
|
|
if (
|
|
!signal.aborted &&
|
|
!toolDenied &&
|
|
!isAbortError(error) &&
|
|
(slot === this.current || slot.drainable)
|
|
) {
|
|
this.reportFailure('run', 'runtime.run.failed', error)
|
|
}
|
|
throw error
|
|
}
|
|
} finally {
|
|
for (const [questionId, owner] of this.questionOwners) {
|
|
if (owner.requestId === request.requestId) this.questionOwners.delete(questionId)
|
|
}
|
|
slot.activeRequests -= 1
|
|
if (
|
|
slot.retiring &&
|
|
!slot.drainable &&
|
|
slot.activeRequests === 0
|
|
) {
|
|
await this.disposeSlot(slot)
|
|
}
|
|
}
|
|
}
|
|
|
|
private reportFailure(
|
|
stage: string,
|
|
code: string,
|
|
error: unknown
|
|
): void {
|
|
try {
|
|
this.observeFailure?.({
|
|
component: 'runtime',
|
|
stage,
|
|
code,
|
|
error
|
|
})
|
|
} catch {
|
|
// Diagnostics must not alter Runtime behavior.
|
|
}
|
|
}
|
|
|
|
async releaseConversation(conversationId: string): Promise<void> {
|
|
const results = await Promise.allSettled([
|
|
this.current.runtime.releaseConversation?.(conversationId),
|
|
...[...this.retired].map((slot) =>
|
|
slot.runtime.releaseConversation?.(conversationId)
|
|
)
|
|
])
|
|
this.ownedConversationIds.delete(conversationId)
|
|
const failures = results.flatMap((result) =>
|
|
result.status === 'rejected' ? [result.reason] : []
|
|
)
|
|
if (failures.length > 0) {
|
|
throw new AggregateError(
|
|
failures,
|
|
'One or more Agent Runtimes failed to release the conversation'
|
|
)
|
|
}
|
|
}
|
|
|
|
async respondToQuestion(
|
|
questionId: string,
|
|
answers?: AgentQuestionAnswer[]
|
|
): Promise<void> {
|
|
const owner = this.questionOwners.get(questionId)
|
|
if (this.closing && !owner) {
|
|
throw new Error('Agent Runtime 正在关闭')
|
|
}
|
|
const runtime = (owner?.slot ?? this.current).runtime
|
|
if (!runtime.respondToQuestion) {
|
|
throw new Error('当前 Runtime 不支持回答交互式问题')
|
|
}
|
|
await runtime.respondToQuestion(questionId, answers)
|
|
this.questionOwners.delete(questionId)
|
|
}
|
|
|
|
private retireSlot(slot: RuntimeSlot): Promise<void> {
|
|
slot.retiring = true
|
|
this.retired.add(slot)
|
|
if (!slot.disposal) {
|
|
slot.disposal = new Promise((resolve) => {
|
|
slot.resolveDisposal = resolve
|
|
})
|
|
}
|
|
if (slot.drainable && !slot.drainStarted) {
|
|
const runtime = slot.runtime as DrainableAgentRuntime
|
|
slot.drainStarted = runtime.beginDrain()
|
|
void slot.drainStarted
|
|
.then(() => runtime.waitForDrain())
|
|
.then(() => this.disposeSlot(slot))
|
|
.catch((error: unknown) => {
|
|
slot.drainError =
|
|
error instanceof Error ? error : new Error(String(error))
|
|
})
|
|
}
|
|
if (!slot.drainable && slot.activeRequests === 0) {
|
|
void this.disposeSlot(slot)
|
|
}
|
|
return slot.disposal
|
|
}
|
|
|
|
private async disposeSlot(
|
|
slot: RuntimeSlot,
|
|
timeoutMs?: number,
|
|
abandonOnTimeout = false
|
|
): Promise<void> {
|
|
if (!slot.resolveDisposal) {
|
|
return
|
|
}
|
|
slot.disposeStarted ??= Promise.resolve().then(() =>
|
|
slot.runtime.dispose()
|
|
)
|
|
let completed = true
|
|
try {
|
|
if (timeoutMs === undefined) {
|
|
await slot.disposeStarted
|
|
} else {
|
|
completed = await waitWithin(
|
|
slot.disposeStarted,
|
|
timeoutMs
|
|
)
|
|
}
|
|
} catch {
|
|
completed = true
|
|
}
|
|
if (!completed && !abandonOnTimeout) {
|
|
return
|
|
}
|
|
const resolve = slot.resolveDisposal
|
|
if (resolve === undefined) {
|
|
return
|
|
}
|
|
slot.resolveDisposal = undefined
|
|
this.retired.delete(slot)
|
|
resolve()
|
|
}
|
|
|
|
retire(): Promise<void> {
|
|
if (this.retirement !== undefined) {
|
|
return this.retirement
|
|
}
|
|
this.closing = true
|
|
const operation = this.replacementQueue.then(async () => {
|
|
this.retireSlot(this.current)
|
|
await Promise.all(
|
|
[...this.retired].map(
|
|
(slot) => slot.disposal ?? Promise.resolve()
|
|
)
|
|
)
|
|
})
|
|
this.retirement = operation
|
|
return operation
|
|
}
|
|
|
|
dispose(): Promise<void> {
|
|
if (this.disposal !== undefined) {
|
|
return this.disposal
|
|
}
|
|
this.closing = true
|
|
const operation = this.replacementQueue.then(async () => {
|
|
const slot = this.current
|
|
this.retireSlot(slot)
|
|
const slots = [...this.retired]
|
|
const disposals = slots.map(
|
|
(candidate) => candidate.disposal ?? Promise.resolve()
|
|
)
|
|
await waitWithin(
|
|
Promise.all(disposals).then(() => undefined),
|
|
this.shutdownGraceMs
|
|
)
|
|
await Promise.all(
|
|
slots.map(async (candidate) => {
|
|
if (!candidate.resolveDisposal) {
|
|
return
|
|
}
|
|
if (candidate.drainable) {
|
|
await waitWithin(
|
|
(
|
|
candidate.runtime as DrainableAgentRuntime
|
|
).forceShutdown().catch(() => undefined),
|
|
this.shutdownGraceMs
|
|
)
|
|
}
|
|
await this.disposeSlot(
|
|
candidate,
|
|
this.shutdownGraceMs,
|
|
true
|
|
)
|
|
})
|
|
)
|
|
await Promise.all(disposals)
|
|
})
|
|
this.disposal = operation
|
|
return operation
|
|
}
|
|
|
|
detachForApplicationExit(): Promise<void> {
|
|
if (this.disposal !== undefined) {
|
|
return this.disposal
|
|
}
|
|
this.closing = true
|
|
const operation = this.replacementQueue.then(async () => {
|
|
const slots = [...new Set([this.current, ...this.retired])]
|
|
const detachable = slots.filter(
|
|
(slot) =>
|
|
typeof slot.runtime.detachForApplicationExit === 'function'
|
|
)
|
|
const ordinary = slots.filter(
|
|
(slot) =>
|
|
typeof slot.runtime.detachForApplicationExit !== 'function'
|
|
)
|
|
|
|
await Promise.allSettled(
|
|
detachable.map(async (slot) => {
|
|
await slot.runtime.detachForApplicationExit?.()
|
|
this.abandonSlot(slot)
|
|
})
|
|
)
|
|
|
|
for (const slot of ordinary) {
|
|
this.retireSlot(slot)
|
|
}
|
|
const disposals = ordinary.map(
|
|
(slot) => slot.disposal ?? Promise.resolve()
|
|
)
|
|
await waitWithin(
|
|
Promise.all(disposals).then(() => undefined),
|
|
this.shutdownGraceMs
|
|
)
|
|
await Promise.all(
|
|
ordinary.map(async (slot) => {
|
|
if (!slot.resolveDisposal) {
|
|
return
|
|
}
|
|
if (slot.drainable) {
|
|
await waitWithin(
|
|
(
|
|
slot.runtime as DrainableAgentRuntime
|
|
).forceShutdown().catch(() => undefined),
|
|
this.shutdownGraceMs
|
|
)
|
|
}
|
|
await this.disposeSlot(
|
|
slot,
|
|
this.shutdownGraceMs,
|
|
true
|
|
)
|
|
})
|
|
)
|
|
await Promise.all(disposals)
|
|
})
|
|
this.disposal = operation
|
|
return operation
|
|
}
|
|
|
|
private abandonSlot(slot: RuntimeSlot): void {
|
|
const resolve = slot.resolveDisposal
|
|
slot.resolveDisposal = undefined
|
|
this.retired.delete(slot)
|
|
resolve?.()
|
|
}
|
|
}
|
|
|
|
function isAbortError(error: unknown): boolean {
|
|
if (typeof error !== 'object' || error === null) {
|
|
return false
|
|
}
|
|
try {
|
|
return 'name' in error && error.name === 'AbortError'
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
async function waitWithin(
|
|
promise: Promise<void>,
|
|
timeoutMs: number
|
|
): Promise<boolean> {
|
|
let timer: ReturnType<typeof setTimeout> | undefined
|
|
try {
|
|
return await Promise.race([
|
|
promise.then(() => true),
|
|
new Promise<false>((resolve) => {
|
|
timer = setTimeout(() => resolve(false), timeoutMs)
|
|
timer.unref?.()
|
|
})
|
|
])
|
|
} finally {
|
|
if (timer !== undefined) {
|
|
clearTimeout(timer)
|
|
}
|
|
}
|
|
}
|