fix: reclaim idle remote runtimes and retire replaced agents
Remote tasks could leave idle Runtime processes behind, and replaced Agent installations lacked a drain-and-exit lifecycle. Keep the current Agent resident, let displaced Agents finish existing work and reconnect for its results, then retire them without restarting obsolete installations. Reclaim Runtime processes when their last active task finishes, preserve pending questions and shared active tasks, and restore conversation history when the next turn starts. Remove the Remote Projects technical preview label from settings, errors, tests, and current documentation; the feature remains disabled by default. Validation recorded during implementation: 213 lifecycle tests and 28 focused UI/IPC tests passed, as did SettingsPanel and lint. Linux x64 real-Host checks used 8 provider requests. Broader tests and final typecheck encountered unrelated concurrent changes; details and coverage limits are documented. Release note: 远程任务结束后自动释放空闲运行进程,下一轮对话恢复历史;更新后的旧 Agent 等待已有任务完成再退出,当前 Agent 保持常驻。远程项目移除“技术预览”标识,仍默认关闭。
This commit is contained in:
@@ -86,7 +86,7 @@ const DEFAULT_MAXIMUM_PENDING_OUTPUT_BYTES_GLOBAL =
|
||||
const DEFAULT_MAXIMUM_OPERATIONS_PER_BINDING = 1_000
|
||||
const DEFAULT_DISPOSE_TIMEOUT_MS = 10_000
|
||||
const OWNED_PROMPT_CANCEL_GRACE_MS = 1_000
|
||||
const SHARED_PROCESS_IDLE_MS = 60_000
|
||||
const SHARED_PROCESS_IDLE_MS = 0
|
||||
const OWNED_PROMPT_START_TIMEOUT_MS = 2 * 60_000
|
||||
const ACP_JOURNAL_RETRY_INITIAL_MILLISECONDS = 25
|
||||
const ACP_JOURNAL_RETRY_MAXIMUM_MILLISECONDS = 1_000
|
||||
@@ -309,6 +309,7 @@ type PreparedOperation = {
|
||||
modelProfile?: AgentPromptModelProfile
|
||||
promptSequence: number
|
||||
ownedPromptStarted?: boolean
|
||||
terminalAcknowledged?: boolean
|
||||
}
|
||||
|
||||
type BindingState = {
|
||||
@@ -374,6 +375,7 @@ export class RuntimeAcpBackend {
|
||||
#pendingOutputBytesGlobal = 0
|
||||
#controlTail: Promise<void> = Promise.resolve()
|
||||
#disposed = false
|
||||
#draining = false
|
||||
#disposePromise?: Promise<void>
|
||||
|
||||
constructor(options: RuntimeAcpBackendOptions) {
|
||||
@@ -636,6 +638,18 @@ export class RuntimeAcpBackend {
|
||||
frame.header.generation === binding.controllerGeneration
|
||||
)
|
||||
|
||||
async drain(): Promise<boolean> {
|
||||
this.#draining = true
|
||||
return this.#enqueueControl(() =>
|
||||
![...this.#bindings.values()].some(binding =>
|
||||
binding.activeOperationId !== undefined ||
|
||||
(binding.transportState !== 'detached' && [...binding.operations.values()].some(operation =>
|
||||
operation.ownedPromptStarted && !operation.terminalAcknowledged
|
||||
))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
dispose(timeoutMs = DEFAULT_DISPOSE_TIMEOUT_MS): Promise<void> {
|
||||
if (this.#disposePromise !== undefined) {
|
||||
return this.#disposePromise
|
||||
@@ -1039,6 +1053,9 @@ export class RuntimeAcpBackend {
|
||||
existing.acceptance
|
||||
)
|
||||
}
|
||||
if (this.#draining) {
|
||||
throw new RuntimeAcpBackendError('Agent installation is draining after update', 'closed')
|
||||
}
|
||||
if (binding.activeOperationId !== undefined) {
|
||||
throw new RuntimeAcpBackendError(
|
||||
'Another prompt operation is still active',
|
||||
@@ -1055,6 +1072,7 @@ export class RuntimeAcpBackend {
|
||||
)
|
||||
}
|
||||
if (
|
||||
binding.process !== undefined &&
|
||||
binding.workMode !== undefined &&
|
||||
binding.workMode !== preparation.workMode &&
|
||||
binding.sharedProcess === undefined
|
||||
@@ -1617,10 +1635,19 @@ export class RuntimeAcpBackend {
|
||||
'process'
|
||||
)
|
||||
}
|
||||
return this.#options.semanticPrompts.acknowledge({
|
||||
const result = this.#options.semanticPrompts.acknowledge({
|
||||
...request,
|
||||
controllerId: context.controller.controllerId
|
||||
})
|
||||
const operation = this.#bindings.get(request.bindingId)?.operations.get(request.operationId)
|
||||
if (operation?.ownedPromptStarted) {
|
||||
const terminal = this.#options.semanticPrompts.attach(
|
||||
request.bindingId, request.operationId, context.controller.controllerId
|
||||
)
|
||||
operation.terminalAcknowledged = terminal.state !== 'starting' && terminal.state !== 'running' &&
|
||||
BigInt(result.acknowledgedSequence) >= BigInt(terminal.latestSemanticSequence)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
async #terminalizeOwnedPrompt(
|
||||
@@ -1683,6 +1710,7 @@ export class RuntimeAcpBackend {
|
||||
clearTimeout(binding.ownedPromptStartTimer)
|
||||
binding.ownedPromptStartTimer = undefined
|
||||
}
|
||||
await this.#releaseIdleProcess(binding)
|
||||
}
|
||||
|
||||
async #startModelBridge(
|
||||
@@ -2195,6 +2223,25 @@ export class RuntimeAcpBackend {
|
||||
() => undefined
|
||||
)
|
||||
}
|
||||
await this.#releaseIdleProcess(binding)
|
||||
}
|
||||
|
||||
async #releaseIdleProcess(binding: BindingState): Promise<void> {
|
||||
if (binding.activeOperationId !== undefined || !binding.process) return
|
||||
const group = binding.sharedProcess
|
||||
if (group) {
|
||||
if ([...group.bindings].some(peer => peer.activeOperationId !== undefined)) return
|
||||
await this.#stopSharedProcess(group, 'binding-closed')
|
||||
} else {
|
||||
await binding.process.stop({
|
||||
reason: 'binding-closed', deadlineAt: new Date(this.#now() + 10_000).toISOString()
|
||||
})
|
||||
const result = await binding.process.reconcile()
|
||||
if (!sameProcessIdentity(result.identity, binding.process.identity) || result.processTree !== 'empty') {
|
||||
throw new RuntimeAcpBackendError('Idle Runtime process stop did not reconcile', 'process')
|
||||
}
|
||||
await this.#handleProcessExit(binding)
|
||||
}
|
||||
}
|
||||
|
||||
async #stopAndReconcile(
|
||||
@@ -2295,6 +2342,15 @@ export class RuntimeAcpBackend {
|
||||
if (binding.process === undefined) {
|
||||
return
|
||||
}
|
||||
if (binding.activeOperationId === undefined) {
|
||||
for (const operation of binding.operations.values()) {
|
||||
operation.processTree = 'empty'
|
||||
if (operation.completion) operation.completion.processTree = 'empty'
|
||||
}
|
||||
this.#finishProcess(binding)
|
||||
binding.state = 'open'
|
||||
return
|
||||
}
|
||||
this.#appendAbnormalOwnedPromptTerminal(binding, {
|
||||
status: this.#abnormalOwnedPromptState(binding),
|
||||
name: 'RuntimeExited',
|
||||
@@ -2466,11 +2522,13 @@ export class RuntimeAcpBackend {
|
||||
}
|
||||
|
||||
#scheduleSharedIdle(group: SharedProcess): void {
|
||||
if (group.bindings.size || group.exited || this.#disposed || group.idleTimer) return
|
||||
if ([...group.bindings].some(binding => binding.activeOperationId !== undefined) || group.exited || this.#disposed || group.idleTimer) return
|
||||
group.idleTimer = setTimeout(() => {
|
||||
group.idleTimer = undefined
|
||||
void this.#enqueueControl(async () => {
|
||||
if (!group.bindings.size) await this.#stopSharedProcess(group, 'binding-closed')
|
||||
if (![...group.bindings].some(binding => binding.activeOperationId !== undefined)) {
|
||||
await this.#stopSharedProcess(group, 'binding-closed')
|
||||
}
|
||||
}).catch(() => undefined)
|
||||
}, this.#options.sharedProcessIdleMs ?? SHARED_PROCESS_IDLE_MS)
|
||||
group.idleTimer.unref?.()
|
||||
|
||||
Reference in New Issue
Block a user