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:
@@ -12,11 +12,12 @@ import type {
|
||||
RuntimeConversationCompactOutcome
|
||||
} from './runtime'
|
||||
import { AgentRuntimeController } from './runtime-controller'
|
||||
import type { ExecutionSpaceDescriptor } from '../execution-space'
|
||||
|
||||
export type SelectedRuntimeResolver = {
|
||||
getRuntime(
|
||||
selection: AgentRuntimeSelection,
|
||||
workspacePath?: string
|
||||
executionSpace?: ExecutionSpaceDescriptor
|
||||
): Promise<AgentRuntime>
|
||||
getStatus(
|
||||
selection: AgentRuntimeSelection
|
||||
@@ -26,65 +27,124 @@ export type SelectedRuntimeResolver = {
|
||||
): Promise<AgentRuntimeStatus>
|
||||
getNativeSnapshot(
|
||||
selection: AgentRuntimeSelection,
|
||||
workspacePath?: string
|
||||
executionSpace?: ExecutionSpaceDescriptor
|
||||
): Promise<RuntimeNativeSnapshot>
|
||||
compactConversation(
|
||||
request: RuntimeConversationCompactInput,
|
||||
workspacePath: string | undefined,
|
||||
executionSpace: ExecutionSpaceDescriptor | undefined,
|
||||
signal: AbortSignal
|
||||
): Promise<RuntimeConversationCompactOutcome>
|
||||
releaseConversation(conversationId: string): Promise<void>
|
||||
reset?(): Promise<void>
|
||||
}
|
||||
|
||||
type RuntimeEntry = {
|
||||
operation: Promise<AgentRuntimeController>
|
||||
controller?: AgentRuntimeController
|
||||
hostId?: string
|
||||
lastUsed: number
|
||||
}
|
||||
|
||||
const DEFAULT_MAXIMUM_CACHED_RUNTIMES = 8
|
||||
const DEFAULT_MAXIMUM_RETAINED_RUNTIMES = 16
|
||||
|
||||
export class SelectedRuntimeManager implements SelectedRuntimeResolver {
|
||||
private readonly entries = new Map<
|
||||
string,
|
||||
Promise<AgentRuntimeController>
|
||||
>()
|
||||
private readonly entries = new Map<string, RuntimeEntry>()
|
||||
private disposed = false
|
||||
private readonly retiring = new Set<Promise<void>>()
|
||||
private readonly retiringControllers =
|
||||
new Set<AgentRuntimeController>()
|
||||
private pendingRetirementControllers = 0
|
||||
private readonly tests = new Set<Promise<AgentRuntimeStatus>>()
|
||||
private readonly snapshots = new Set<
|
||||
Promise<RuntimeNativeSnapshot>
|
||||
>()
|
||||
private useSequence = 0
|
||||
|
||||
constructor(
|
||||
private readonly createRuntime: (
|
||||
selection: AgentRuntimeSelection,
|
||||
workspacePath?: string
|
||||
) => Promise<AgentRuntime>
|
||||
) {}
|
||||
executionSpace?: ExecutionSpaceDescriptor
|
||||
) => Promise<AgentRuntime>,
|
||||
private readonly maximumCachedRuntimes =
|
||||
DEFAULT_MAXIMUM_CACHED_RUNTIMES,
|
||||
private readonly maximumRetainedRuntimes =
|
||||
DEFAULT_MAXIMUM_RETAINED_RUNTIMES
|
||||
) {
|
||||
if (
|
||||
!Number.isSafeInteger(maximumCachedRuntimes) ||
|
||||
maximumCachedRuntimes < 1 ||
|
||||
maximumCachedRuntimes > 64
|
||||
) {
|
||||
throw new RangeError('Invalid selected Runtime cache limit')
|
||||
}
|
||||
if (
|
||||
!Number.isSafeInteger(maximumRetainedRuntimes) ||
|
||||
maximumRetainedRuntimes < maximumCachedRuntimes ||
|
||||
maximumRetainedRuntimes > 64
|
||||
) {
|
||||
throw new RangeError('Invalid retained Runtime limit')
|
||||
}
|
||||
}
|
||||
|
||||
async getRuntime(
|
||||
selection: AgentRuntimeSelection,
|
||||
workspacePath?: string
|
||||
executionSpace?: ExecutionSpaceDescriptor
|
||||
): Promise<AgentRuntimeController> {
|
||||
if (this.disposed) {
|
||||
throw new Error('Agent Runtime 正在关闭')
|
||||
}
|
||||
const key = JSON.stringify([
|
||||
agentRuntimeSelectionKey(selection),
|
||||
workspacePath ?? ''
|
||||
executionSpace?.cacheIdentity ?? null
|
||||
])
|
||||
const existing = this.entries.get(key)
|
||||
if (existing) {
|
||||
return existing
|
||||
existing.lastUsed = ++this.useSequence
|
||||
return existing.operation
|
||||
}
|
||||
const operation = this.createRuntime(selection, workspacePath).then(
|
||||
this.evictIdleEntries(this.maximumCachedRuntimes - 1)
|
||||
if (this.entries.size >= this.maximumCachedRuntimes) {
|
||||
throw new Error(
|
||||
'Agent Runtime 缓存已被活动会话占满,请先关闭不再使用的会话'
|
||||
)
|
||||
}
|
||||
if (
|
||||
this.entries.size +
|
||||
this.retiringControllers.size +
|
||||
this.pendingRetirementControllers >=
|
||||
this.maximumRetainedRuntimes
|
||||
) {
|
||||
throw new Error(
|
||||
'Agent Runtime 退役容量已满,请先关闭仍由旧设置持有的会话'
|
||||
)
|
||||
}
|
||||
const operation = this.createRuntime(selection, executionSpace).then(
|
||||
async (runtime) => {
|
||||
if (this.disposed || this.entries.get(key) !== operation) {
|
||||
if (
|
||||
this.disposed ||
|
||||
this.entries.get(key)?.operation !== operation
|
||||
) {
|
||||
await runtime.dispose()
|
||||
throw new Error('Runtime 设置已更改,请重新选择')
|
||||
}
|
||||
return new AgentRuntimeController(runtime)
|
||||
const controller = new AgentRuntimeController(runtime)
|
||||
entry.controller = controller
|
||||
return controller
|
||||
}
|
||||
)
|
||||
this.entries.set(key, operation)
|
||||
const entry: RuntimeEntry = {
|
||||
operation,
|
||||
...(executionSpace?.kind === 'ssh'
|
||||
? { hostId: executionSpace.hostId }
|
||||
: {}),
|
||||
lastUsed: ++this.useSequence
|
||||
}
|
||||
this.entries.set(key, entry)
|
||||
try {
|
||||
return await operation
|
||||
} catch (error) {
|
||||
if (this.entries.get(key) === operation) {
|
||||
if (this.entries.get(key) === entry) {
|
||||
this.entries.delete(key)
|
||||
}
|
||||
throw error
|
||||
@@ -114,14 +174,14 @@ export class SelectedRuntimeManager implements SelectedRuntimeResolver {
|
||||
|
||||
async getNativeSnapshot(
|
||||
selection: AgentRuntimeSelection,
|
||||
workspacePath?: string
|
||||
executionSpace?: ExecutionSpaceDescriptor
|
||||
): Promise<RuntimeNativeSnapshot> {
|
||||
if (this.disposed) {
|
||||
throw new Error('Agent Runtime 正在关闭')
|
||||
}
|
||||
const operation = this.runNativeSnapshot(
|
||||
selection,
|
||||
workspacePath
|
||||
executionSpace
|
||||
)
|
||||
this.snapshots.add(operation)
|
||||
try {
|
||||
@@ -133,27 +193,31 @@ export class SelectedRuntimeManager implements SelectedRuntimeResolver {
|
||||
|
||||
async compactConversation(
|
||||
request: RuntimeConversationCompactInput,
|
||||
workspacePath: string | undefined,
|
||||
executionSpace: ExecutionSpaceDescriptor | undefined,
|
||||
signal: AbortSignal
|
||||
): Promise<RuntimeConversationCompactOutcome> {
|
||||
const runtime = await this.getRuntime(
|
||||
request.runtimeSelection,
|
||||
workspacePath
|
||||
executionSpace
|
||||
)
|
||||
return runtime.compactConversation(request, signal)
|
||||
}
|
||||
|
||||
async releaseConversation(conversationId: string): Promise<void> {
|
||||
const controllers = await Promise.allSettled([
|
||||
...this.entries.values()
|
||||
...[...this.entries.values()].map(({ operation }) => operation)
|
||||
])
|
||||
const current = controllers.flatMap((result) =>
|
||||
result.status === 'fulfilled' ? [result.value] : []
|
||||
)
|
||||
await Promise.allSettled(
|
||||
controllers.flatMap((result) =>
|
||||
result.status === 'fulfilled'
|
||||
? [result.value.releaseConversation(conversationId)]
|
||||
: []
|
||||
[...new Set([...current, ...this.retiringControllers])].map(
|
||||
async (controller) => {
|
||||
await controller.releaseConversation(conversationId)
|
||||
}
|
||||
)
|
||||
)
|
||||
this.evictIdleEntries(this.maximumCachedRuntimes)
|
||||
}
|
||||
|
||||
async reset(): Promise<void> {
|
||||
@@ -164,6 +228,20 @@ export class SelectedRuntimeManager implements SelectedRuntimeResolver {
|
||||
)
|
||||
}
|
||||
|
||||
async invalidateHost(hostId: string): Promise<void> {
|
||||
const entries: RuntimeEntry[] = []
|
||||
for (const [key, entry] of this.entries) {
|
||||
if (entry.hostId !== hostId) {
|
||||
continue
|
||||
}
|
||||
this.entries.delete(key)
|
||||
entries.push(entry)
|
||||
}
|
||||
await Promise.allSettled(
|
||||
entries.map((entry) => this.startRetiring(entry, false))
|
||||
)
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
this.disposed = true
|
||||
const entries = [...this.entries.values()]
|
||||
@@ -171,6 +249,11 @@ export class SelectedRuntimeManager implements SelectedRuntimeResolver {
|
||||
await Promise.allSettled(
|
||||
entries.map((entry) => this.startRetiring(entry, true))
|
||||
)
|
||||
await Promise.allSettled(
|
||||
[...this.retiringControllers].map(
|
||||
async (controller) => await controller.dispose()
|
||||
)
|
||||
)
|
||||
await Promise.allSettled([...this.tests])
|
||||
await Promise.allSettled([...this.snapshots])
|
||||
await Promise.allSettled([...this.retiring])
|
||||
@@ -195,9 +278,9 @@ export class SelectedRuntimeManager implements SelectedRuntimeResolver {
|
||||
|
||||
private async runNativeSnapshot(
|
||||
selection: AgentRuntimeSelection,
|
||||
workspacePath?: string
|
||||
executionSpace?: ExecutionSpaceDescriptor
|
||||
): Promise<RuntimeNativeSnapshot> {
|
||||
const runtime = await this.createRuntime(selection, workspacePath)
|
||||
const runtime = await this.createRuntime(selection, executionSpace)
|
||||
try {
|
||||
if (this.disposed) {
|
||||
throw new Error('Agent Runtime 正在关闭')
|
||||
@@ -212,22 +295,75 @@ export class SelectedRuntimeManager implements SelectedRuntimeResolver {
|
||||
}
|
||||
|
||||
private async startRetiring(
|
||||
entry: Promise<AgentRuntimeController>,
|
||||
entry: RuntimeEntry,
|
||||
waitForDisposal: boolean
|
||||
): Promise<void> {
|
||||
this.pendingRetirementControllers += 1
|
||||
let controllerPending = true
|
||||
try {
|
||||
const controller = await entry
|
||||
const disposal = controller.dispose()
|
||||
this.retiring.add(disposal)
|
||||
void disposal.then(
|
||||
() => this.retiring.delete(disposal),
|
||||
() => this.retiring.delete(disposal)
|
||||
const controller =
|
||||
entry.controller ?? await entry.operation
|
||||
this.pendingRetirementControllers -= 1
|
||||
controllerPending = false
|
||||
const disposal = this.trackRetiringController(
|
||||
controller,
|
||||
waitForDisposal
|
||||
)
|
||||
if (waitForDisposal) {
|
||||
await disposal
|
||||
}
|
||||
} catch {
|
||||
return
|
||||
} finally {
|
||||
if (controllerPending) {
|
||||
this.pendingRetirementControllers -= 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private trackRetiringController(
|
||||
controller: AgentRuntimeController,
|
||||
force: boolean
|
||||
): Promise<void> {
|
||||
this.retiringControllers.add(controller)
|
||||
const disposal = force
|
||||
? controller.dispose()
|
||||
: controller.retire()
|
||||
this.retiring.add(disposal)
|
||||
void disposal.then(
|
||||
() => {
|
||||
this.retiring.delete(disposal)
|
||||
this.retiringControllers.delete(controller)
|
||||
},
|
||||
() => {
|
||||
this.retiring.delete(disposal)
|
||||
this.retiringControllers.delete(controller)
|
||||
}
|
||||
)
|
||||
return disposal
|
||||
}
|
||||
|
||||
private evictIdleEntries(targetSize: number): void {
|
||||
if (this.entries.size <= targetSize) {
|
||||
return
|
||||
}
|
||||
const candidates = [...this.entries.entries()]
|
||||
.filter((entry): entry is [string, RuntimeEntry] => {
|
||||
const controller = entry[1].controller
|
||||
return controller !== undefined && controller.canRetire
|
||||
})
|
||||
.sort(
|
||||
(left, right) => left[1].lastUsed - right[1].lastUsed
|
||||
)
|
||||
for (const [key, entry] of candidates) {
|
||||
if (this.entries.size <= targetSize) {
|
||||
break
|
||||
}
|
||||
if (this.entries.get(key) !== entry) {
|
||||
continue
|
||||
}
|
||||
this.entries.delete(key)
|
||||
void this.startRetiring(entry, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user