feat: add managed SSH execution spaces
Add managed SSH Hosts and projects with signed side-by-side GoodBuddy Agent and OpenCode Runtime installation, Ask and Execute workspace access, a Main-owned model bridge, detached Agent lifecycle, durable ACP output recovery, and project activation UI. Use protocol v2 with bounded FIFO frames over SSH instead of custom credit, priority, batching, or model-message chunking. Mismatched Host Agents now upgrade automatically, an exactly verified Node runtime is reused on-host, and local Portable builds can embed strictly verified Agent artifacts. Release note: 新增托管 SSH 远程项目,可自动安装或升级签名 Agent/OpenCode Runtime,并在 SSH 断线后继续 Agent 工作和同步未确认输出。
This commit is contained in:
@@ -16,14 +16,67 @@ 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>()
|
||||
|
||||
constructor(
|
||||
runtime: AgentRuntime,
|
||||
@@ -32,7 +85,8 @@ export class AgentRuntimeController implements AgentRuntime {
|
||||
this.current = {
|
||||
runtime,
|
||||
activeRequests: 0,
|
||||
retiring: false
|
||||
retiring: false,
|
||||
drainable: isDrainableRuntime(runtime)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,15 +110,69 @@ export class AgentRuntimeController implements AgentRuntime {
|
||||
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 canRetire(): boolean {
|
||||
return (
|
||||
!this.closing &&
|
||||
this.activeRequestCount === 0 &&
|
||||
this.ownedConversationIds.size === 0
|
||||
)
|
||||
}
|
||||
|
||||
replace(next: AgentRuntime): Promise<void> {
|
||||
if (this.closing) {
|
||||
return next.dispose().then(() => {
|
||||
return this.disposeRejectedRuntime(next).then(() => {
|
||||
throw new Error('Agent Runtime 正在关闭')
|
||||
})
|
||||
}
|
||||
const operation = this.replacementQueue.then(() =>
|
||||
this.performReplace(next)
|
||||
)
|
||||
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
|
||||
}
|
||||
@@ -74,13 +182,22 @@ export class AgentRuntimeController implements AgentRuntime {
|
||||
this.current = {
|
||||
runtime: next,
|
||||
activeRequests: 0,
|
||||
retiring: false
|
||||
retiring: false,
|
||||
drainable: isDrainableRuntime(next)
|
||||
}
|
||||
const disposal = this.retire(previous)
|
||||
await Promise.race([
|
||||
disposal,
|
||||
new Promise<void>((resolve) => setTimeout(resolve, 2_000))
|
||||
])
|
||||
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> {
|
||||
@@ -107,6 +224,7 @@ export class AgentRuntimeController implements AgentRuntime {
|
||||
request: RuntimeConversationCompactInput,
|
||||
signal: AbortSignal
|
||||
): Promise<RuntimeConversationCompactOutcome> {
|
||||
this.ownedConversationIds.add(request.conversationId)
|
||||
return this.invoke((runtime) => {
|
||||
if (!runtime.compactConversation) {
|
||||
throw new Error('当前 Runtime 不支持手动压缩')
|
||||
@@ -141,7 +259,11 @@ export class AgentRuntimeController implements AgentRuntime {
|
||||
return status
|
||||
} finally {
|
||||
slot.activeRequests -= 1
|
||||
if (slot.retiring && slot.activeRequests === 0) {
|
||||
if (
|
||||
slot.retiring &&
|
||||
!slot.drainable &&
|
||||
slot.activeRequests === 0
|
||||
) {
|
||||
await this.disposeSlot(slot)
|
||||
}
|
||||
}
|
||||
@@ -156,6 +278,7 @@ export class AgentRuntimeController implements AgentRuntime {
|
||||
throw new Error('Agent Runtime 正在关闭')
|
||||
}
|
||||
const slot = this.current
|
||||
this.ownedConversationIds.add(request.conversationId)
|
||||
const toolsAllowed = request.workMode === 'execute'
|
||||
const effectiveAuthorize: RuntimeAuthorizer | undefined = toolsAllowed
|
||||
? authorize
|
||||
@@ -186,24 +309,43 @@ export class AgentRuntimeController implements AgentRuntime {
|
||||
signal,
|
||||
effectiveAuthorize
|
||||
)) {
|
||||
if (slot !== this.current) {
|
||||
if (slot !== this.current && !slot.drainable) {
|
||||
throw new Error('Runtime 已切换,当前请求已中断')
|
||||
}
|
||||
yield event
|
||||
}
|
||||
if (slot !== this.current) {
|
||||
if (slot !== this.current && !slot.drainable) {
|
||||
throw new Error('Runtime 已切换,当前请求已中断')
|
||||
}
|
||||
} finally {
|
||||
slot.activeRequests -= 1
|
||||
if (slot.retiring && slot.activeRequests === 0) {
|
||||
if (
|
||||
slot.retiring &&
|
||||
!slot.drainable &&
|
||||
slot.activeRequests === 0
|
||||
) {
|
||||
await this.disposeSlot(slot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async releaseConversation(conversationId: string): Promise<void> {
|
||||
await this.current.runtime.releaseConversation?.(conversationId)
|
||||
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(
|
||||
@@ -220,52 +362,143 @@ export class AgentRuntimeController implements AgentRuntime {
|
||||
await runtime.respondToQuestion(questionId, answers)
|
||||
}
|
||||
|
||||
private retire(slot: RuntimeSlot): Promise<void> {
|
||||
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.activeRequests === 0) {
|
||||
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): Promise<void> {
|
||||
private async disposeSlot(
|
||||
slot: RuntimeSlot,
|
||||
timeoutMs?: number,
|
||||
abandonOnTimeout = false
|
||||
): Promise<void> {
|
||||
if (!slot.resolveDisposal) {
|
||||
return
|
||||
}
|
||||
const resolve = slot.resolveDisposal
|
||||
slot.resolveDisposal = undefined
|
||||
slot.disposeStarted ??= Promise.resolve().then(() =>
|
||||
slot.runtime.dispose()
|
||||
)
|
||||
let completed = true
|
||||
try {
|
||||
await slot.runtime.dispose()
|
||||
if (timeoutMs === undefined) {
|
||||
await slot.disposeStarted
|
||||
} else {
|
||||
completed = await waitWithin(
|
||||
slot.disposeStarted,
|
||||
timeoutMs
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
resolve()
|
||||
completed = true
|
||||
}
|
||||
if (!completed && !abandonOnTimeout) {
|
||||
return
|
||||
}
|
||||
const resolve = slot.resolveDisposal
|
||||
if (resolve === undefined) {
|
||||
return
|
||||
}
|
||||
slot.resolveDisposal = undefined
|
||||
this.retired.delete(slot)
|
||||
resolve()
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
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
|
||||
const disposal = this.retire(slot)
|
||||
if (slot.activeRequests === 0) {
|
||||
return disposal
|
||||
}
|
||||
await Promise.race([
|
||||
disposal,
|
||||
new Promise<void>((resolve) =>
|
||||
setTimeout(resolve, this.shutdownGraceMs)
|
||||
)
|
||||
])
|
||||
await this.disposeSlot(slot)
|
||||
return disposal
|
||||
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.replacementQueue = operation.catch(() => undefined)
|
||||
await operation
|
||||
this.disposal = operation
|
||||
return operation
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user