feat: keep managed SSH prompts running on Hosts
Managed SSH prompts previously depended on Desktop-owned provider and transcript state, so accepted work could stop or become uncertain when the Desktop, network, or power disappeared. The Agent now owns accepted OpenCode prompt execution, model and tool rounds, cancellation, and the durable semantic transcript, while Desktop remains authoritative for permanent projects, conversations, tasks, and messages. Recovered work attaches to the original conversation without replay. The project switcher also groups managed SSH projects by saved Host and shows live Agent connectivity beside each group without opening a connection merely to read status. Remote project subtitles now show only the path, and the Agent lock advances to 0.11.14 with Runtime ACP v4. Release note: 托管 SSH 任务在桌面端、网络或电源中断后可继续在主机上运行,并能回到原会话精确恢复;项目菜单现按主机分组并显示实时连接状态。
This commit is contained in:
@@ -1,247 +1,83 @@
|
||||
import { isDeepStrictEqual } from 'node:util'
|
||||
import {
|
||||
agentPromptModelProfileSchema,
|
||||
modelBridgePolicySchema,
|
||||
type AgentPromptModelProfile,
|
||||
type ModelBridgePolicy
|
||||
} from '../../shared/model-bridge-contracts'
|
||||
import { runtimeSessionBindingSchema } from '../../shared/remote-agent-contracts'
|
||||
import {
|
||||
RemoteModelGateway,
|
||||
RemoteModelGatewayError,
|
||||
createResolvedModelProfileDigest
|
||||
} from '../agent/remote-model-gateway'
|
||||
import { ModelCallCapacityError } from '../agent/model-call-operation-store'
|
||||
import type { RuntimeSessionBindingStore } from '../agent/runtime-session-binding-store'
|
||||
import { createResolvedModelProfileDigest } from '../agent/remote-model-gateway'
|
||||
import type { ResolvedModelProfile } from '../runtime-settings-store'
|
||||
import {
|
||||
MainModelBridgeDispatchError,
|
||||
type MainModelBridgeDispatchInput
|
||||
} from './main-model-bridge-dispatcher'
|
||||
import type { ProtocolRemoteRuntimeChannelOptions } from './protocol-remote-runtime-channel'
|
||||
|
||||
const PRE_DISPATCH_GATEWAY_ERRORS = new Set([
|
||||
'invalid-provider-endpoint',
|
||||
'missing-provider-credential',
|
||||
'model-profile-digest-mismatch',
|
||||
'protocol-path-mismatch',
|
||||
'request-policy-mismatch'
|
||||
])
|
||||
const DEFAULT_MAXIMUM_OUTPUT_TOKENS = 32_000
|
||||
const MAXIMUM_MODEL_CALLS_PER_PROMPT = 100
|
||||
const MODEL_REQUEST_TIMEOUT_MILLISECONDS = 60_000
|
||||
|
||||
/**
|
||||
* Prompt-scoped model material passed directly to the Agent. The policy is
|
||||
* safe to persist; the profile (and its credential) is not.
|
||||
*/
|
||||
export type ManagedModelBridge = {
|
||||
policy: ModelBridgePolicy
|
||||
channel: NonNullable<ProtocolRemoteRuntimeChannelOptions['modelBridge']>
|
||||
profile: AgentPromptModelProfile
|
||||
}
|
||||
|
||||
export function createManagedModelBridge(options: {
|
||||
profile: ResolvedModelProfile
|
||||
gateway: RemoteModelGateway
|
||||
bindingStore: RuntimeSessionBindingStore
|
||||
}): ManagedModelBridge {
|
||||
const profile = structuredClone(options.profile)
|
||||
const selected = structuredClone(options.profile)
|
||||
if (
|
||||
profile.protocol === 'openai-images-generations' ||
|
||||
(profile.authentication === 'api-key' && !profile.apiKey)
|
||||
selected.protocol === 'openai-images-generations' ||
|
||||
(selected.authentication === 'api-key' && !selected.apiKey)
|
||||
) {
|
||||
throw new Error(
|
||||
'Managed remote OpenCode requires a usable text model profile'
|
||||
)
|
||||
}
|
||||
const maximumOutputTokens = Math.min(
|
||||
1_000_000,
|
||||
Math.max(
|
||||
1,
|
||||
selected.maximumOutputTokens ??
|
||||
DEFAULT_MAXIMUM_OUTPUT_TOKENS
|
||||
)
|
||||
)
|
||||
const modelProfileDigest =
|
||||
createResolvedModelProfileDigest(selected)
|
||||
const profile = agentPromptModelProfileSchema.parse({
|
||||
profileId: selected.id,
|
||||
modelProfileDigest,
|
||||
provider:
|
||||
selected.protocol === 'anthropic-messages'
|
||||
? 'anthropic'
|
||||
: 'openai',
|
||||
baseUrl: selected.baseUrl,
|
||||
model: selected.modelName,
|
||||
protocol: selected.protocol,
|
||||
authentication: selected.authentication,
|
||||
...(selected.apiKey === undefined
|
||||
? {}
|
||||
: { apiKey: selected.apiKey }),
|
||||
capabilities: {
|
||||
imageInput: selected.supportsImageInput === true
|
||||
},
|
||||
limits: {
|
||||
maximumOutputTokens,
|
||||
maximumModelCalls: MAXIMUM_MODEL_CALLS_PER_PROMPT,
|
||||
maximumTotalOutputTokens: Math.min(
|
||||
10_000_000,
|
||||
maximumOutputTokens * MAXIMUM_MODEL_CALLS_PER_PROMPT
|
||||
),
|
||||
requestTimeoutMilliseconds:
|
||||
MODEL_REQUEST_TIMEOUT_MILLISECONDS
|
||||
}
|
||||
})
|
||||
const policy = modelBridgePolicySchema.parse({
|
||||
protocol: profile.protocol,
|
||||
model: profile.modelName,
|
||||
modelProfileDigest: createResolvedModelProfileDigest(profile),
|
||||
supportsImageInput: profile.supportsImageInput === true
|
||||
model: profile.model,
|
||||
modelProfileDigest: profile.modelProfileDigest,
|
||||
supportsImageInput: profile.capabilities.imageInput
|
||||
})
|
||||
|
||||
const assertActiveBinding = async (
|
||||
input: Pick<
|
||||
MainModelBridgeDispatchInput['identity'],
|
||||
'bindingId' | 'promptOperationId'
|
||||
>
|
||||
) => {
|
||||
const binding = await options.bindingStore.getById(input.bindingId)
|
||||
if (
|
||||
binding === undefined ||
|
||||
binding.state !== 'prompt-running' ||
|
||||
binding.activePromptOperationId !== input.promptOperationId ||
|
||||
binding.modelBridgeVersion !== 'goodbuddy-model-bridge-v1' ||
|
||||
!isDeepStrictEqual(binding.modelBridgePolicy, policy)
|
||||
) {
|
||||
throw new MainModelBridgeDispatchError(
|
||||
'binding-policy-mismatch',
|
||||
{ outcomeUnknown: false, postDispatch: false }
|
||||
)
|
||||
}
|
||||
return binding
|
||||
}
|
||||
|
||||
return {
|
||||
policy,
|
||||
channel: {
|
||||
dispatch: async (input, signal) => {
|
||||
const binding = await assertActiveBinding(input.identity)
|
||||
if (!isDeepStrictEqual(input.policy, policy)) {
|
||||
throw new MainModelBridgeDispatchError(
|
||||
'model-policy-mismatch',
|
||||
{ outcomeUnknown: false, postDispatch: false }
|
||||
)
|
||||
}
|
||||
try {
|
||||
return await options.gateway.dispatch(
|
||||
{
|
||||
requestId: input.identity.requestId,
|
||||
bindingId: input.identity.bindingId,
|
||||
promptOperationId:
|
||||
input.identity.promptOperationId,
|
||||
promptSequence: binding.promptSequence,
|
||||
roundIndex: input.identity.roundIndex,
|
||||
modelProfileDigest: policy.modelProfileDigest,
|
||||
modelProfile: profile
|
||||
},
|
||||
input.request,
|
||||
signal
|
||||
)
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof MainModelBridgeDispatchError
|
||||
) {
|
||||
throw error
|
||||
}
|
||||
if (
|
||||
error instanceof RemoteModelGatewayError &&
|
||||
PRE_DISPATCH_GATEWAY_ERRORS.has(error.code)
|
||||
) {
|
||||
throw new MainModelBridgeDispatchError(error.code, {
|
||||
outcomeUnknown: false,
|
||||
postDispatch: false,
|
||||
cause: error
|
||||
})
|
||||
}
|
||||
if (error instanceof ModelCallCapacityError) {
|
||||
throw new MainModelBridgeDispatchError(
|
||||
'model-ledger-capacity',
|
||||
{
|
||||
outcomeUnknown: false,
|
||||
postDispatch: false,
|
||||
cause: error
|
||||
}
|
||||
)
|
||||
}
|
||||
throw new MainModelBridgeDispatchError(
|
||||
error instanceof RemoteModelGatewayError
|
||||
? error.code
|
||||
: 'dispatch-failed',
|
||||
{
|
||||
outcomeUnknown: true,
|
||||
postDispatch: true,
|
||||
cause: error
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
onDelivered: async (identity) => {
|
||||
await assertActiveBinding(identity)
|
||||
options.gateway.markResponseDelivered({
|
||||
bindingId: identity.bindingId,
|
||||
promptOperationId: identity.promptOperationId,
|
||||
roundIndex: identity.roundIndex
|
||||
})
|
||||
},
|
||||
finalizePrompt: async (identity) => {
|
||||
const binding = await assertActiveBinding(identity)
|
||||
options.gateway.finalizePrompt({
|
||||
bindingId: identity.bindingId,
|
||||
promptOperationId: identity.promptOperationId,
|
||||
promptSequence: binding.promptSequence
|
||||
})
|
||||
},
|
||||
poison: async (
|
||||
bindingId,
|
||||
promptOperationId
|
||||
) => {
|
||||
const binding =
|
||||
await options.bindingStore.getById(bindingId)
|
||||
if (
|
||||
binding === undefined ||
|
||||
binding.state === 'closed' ||
|
||||
binding.state === 'outcome-unknown' ||
|
||||
binding.activePromptOperationId !== promptOperationId
|
||||
) {
|
||||
return
|
||||
}
|
||||
await options.bindingStore.put(
|
||||
runtimeSessionBindingSchema.parse({
|
||||
...binding,
|
||||
state: 'outcome-unknown'
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
profile: structuredClone(profile),
|
||||
policy
|
||||
}
|
||||
}
|
||||
|
||||
export async function reconcileStartupModelCalls(options: {
|
||||
gatewayStore: {
|
||||
listStartupRecords(input?: {
|
||||
cursor?: {
|
||||
updatedAt: number
|
||||
callOperationId: string
|
||||
}
|
||||
limit?: number
|
||||
}): {
|
||||
records: Array<{
|
||||
status:
|
||||
| 'prepared'
|
||||
| 'dispatched'
|
||||
| 'completed'
|
||||
| 'failed-definitive'
|
||||
| 'outcome-unknown'
|
||||
identity: {
|
||||
bindingId: string
|
||||
promptOperationId: string
|
||||
}
|
||||
}>
|
||||
nextCursor?: {
|
||||
updatedAt: number
|
||||
callOperationId: string
|
||||
}
|
||||
}
|
||||
}
|
||||
bindingStore: RuntimeSessionBindingStore
|
||||
}): Promise<void> {
|
||||
let cursor:
|
||||
| { updatedAt: number; callOperationId: string }
|
||||
| undefined
|
||||
do {
|
||||
const page = options.gatewayStore.listStartupRecords({
|
||||
...(cursor === undefined ? {} : { cursor }),
|
||||
limit: 100
|
||||
})
|
||||
for (const record of page.records) {
|
||||
const binding = await options.bindingStore.getById(
|
||||
record.identity.bindingId
|
||||
)
|
||||
if (
|
||||
binding === undefined ||
|
||||
binding.state === 'closed' ||
|
||||
binding.state === 'outcome-unknown' ||
|
||||
binding.activePromptOperationId !==
|
||||
record.identity.promptOperationId
|
||||
) {
|
||||
continue
|
||||
}
|
||||
await options.bindingStore.put(
|
||||
runtimeSessionBindingSchema.parse({
|
||||
...binding,
|
||||
state:
|
||||
record.status === 'prepared'
|
||||
? 'interrupted'
|
||||
: 'outcome-unknown',
|
||||
...(record.status === 'prepared'
|
||||
? { activePromptOperationId: undefined }
|
||||
: {})
|
||||
})
|
||||
)
|
||||
}
|
||||
cursor = page.nextCursor
|
||||
} while (cursor !== undefined)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user