fix: make Host environment updates fast and recoverable
GoodBuddy Agents / Validate Agent source (push) Canceled after 0s
GoodBuddy Agents / Build Linux arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build Linux x64 Agent (push) Canceled after 0s
Cross-platform packages / Validate source (push) Canceled after 0s
GoodBuddy Agent Release / Authorize Agent release (push) Canceled after 0s
GoodBuddy Agent Release / Publish Agent to GitHub and OSS (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
GoodBuddy Agent Release / Build Linux arm64 compound package (push) Canceled after 0s
GoodBuddy Agent Release / Build Linux x64 compound package (push) Canceled after 0s
GoodBuddy Agent Release / Sign cumulative Agent catalog (push) Canceled after 0s

Published Host updates could fail while the desktop reverified Runtime against
its bundled lock and repeatedly traversed remote payloads. Host preparation now
verifies and extracts once, adopts Agent and Runtime on one pinned connection,
and uses registered metadata for normal health, capability, and prompt paths.
Interrupted commits and adoption retain bounded recovery and rollback state.

Desktop and Agent versions are 0.11.8. Remote Projects remain a technical
preview; the Agent targets Linux x64 and arm64, with no LoongArch preview.

Release note: 修复正式环境 Host 更新失败并移除重复远端包体扫描;中断恢复与清理失败不再破坏已健康环境。
This commit is contained in:
mesalogo
2026-08-29 17:20:25 +08:00
parent ff4fde282f
commit a99b90cc02
42 changed files with 3292 additions and 5559 deletions
@@ -100,6 +100,8 @@ type FixtureOptions = {
prepare?: ReturnType<typeof vi.fn>
prepareUploaded?: ReturnType<typeof vi.fn>
commit?: ReturnType<typeof vi.fn>
commitStatus?: ReturnType<typeof vi.fn>
cleanup?: ReturnType<typeof vi.fn>
uploadFile?: ReturnType<typeof vi.fn>
activateAgent?: ReturnType<typeof vi.fn>
activateRuntime?: ReturnType<typeof vi.fn>
@@ -200,9 +202,22 @@ function fixture(options: FixtureOptions = {}) {
close: vi.fn(() => calls.push('close-upload'))
}
const regularRelease = vi.fn(() => calls.push('release-probe'))
const stopCandidate = vi.fn(async () => {
const stopCandidate = vi.fn(async (
_installationId?: string,
_action?: string
) => {
void _installationId
void _action
calls.push('stop-candidate')
})
const activateAgent = options.activateAgent ?? vi.fn(async () => {
calls.push('agent')
return { exitCode: 0, stdout: '{}', stderr: '' }
})
const activateRuntime = options.activateRuntime ?? vi.fn(async () => {
calls.push('runtime')
return { exitCode: 0, stdout: '{}', stderr: '' }
})
const regularLease = {
identity: leaseIdentity,
isUsable: vi.fn(() => true),
@@ -226,7 +241,26 @@ function fixture(options: FixtureOptions = {}) {
calls.push('open-metadata')
return metadata
}),
runAgentLifecycleAction: stopCandidate,
runAgentLifecycleAction: vi.fn(async (
installationId: string,
action: string
) => {
if (action === 'stop') {
await stopCandidate(installationId, action)
return { exitCode: 0, stdout: '{}', stderr: '' }
}
return (activateAgent as (
installationId: string,
action: string
) => Promise<unknown>)(installationId, action)
}),
runAgentRuntimeAction: vi.fn(async (
installationId: string,
action: unknown
) => (activateRuntime as (
installationId: string,
action: unknown
) => Promise<unknown>)(installationId, action)),
release: regularRelease
}
const prepare = options.prepare ?? vi.fn(async (
@@ -264,11 +298,11 @@ function fixture(options: FixtureOptions = {}) {
identity: structuredClone(identity)
}
})
const cleanup = vi.fn(async (operationId: string) => {
const cleanup = options.cleanup ?? vi.fn(async (operationId: string) => {
calls.push('cleanup')
return { cleaned: true, operationId }
})
const commitStatus = vi.fn(async (
const commitStatus = options.commitStatus ?? vi.fn(async (
input: { operationId: string }
) => ({
committed: true,
@@ -326,13 +360,6 @@ function fixture(options: FixtureOptions = {}) {
calls.push('acquire-archive')
return archive
})
const activateAgent = options.activateAgent ?? vi.fn(async () => {
calls.push('agent')
return identity.agent
})
const activateRuntime = options.activateRuntime ?? vi.fn(async () => {
calls.push('runtime')
})
let pendingOperation = options.pendingOperation
const operationStore = {
load: vi.fn(async () => pendingOperation),
@@ -367,12 +394,6 @@ function fixture(options: FixtureOptions = {}) {
acquireInstallArchive,
acquireGoodBuddyInstallArchive
},
agentInstallationManager: {
activatePublished: activateAgent
} as never,
runtimeInstallationManager: {
activatePublished: activateRuntime
} as never,
operationStore
})
return {
@@ -425,13 +446,11 @@ describe('RemoteEnvironmentPreparer', () => {
'save-operation',
'prepare',
'open-metadata',
'save-operation',
'resolve',
'commit',
'resolve',
'agent',
'resolve',
'runtime',
'resolve',
'cleanup',
'remove-operation',
'close-metadata',
@@ -447,23 +466,24 @@ describe('RemoteEnvironmentPreparer', () => {
expect(remoteCandidate.operationId).toMatch(/^[0-9a-f-]{36}$/u)
expect(value.commit.mock.calls[0]?.[0]).toEqual(remoteCandidate)
expect(value.activateAgent).toHaveBeenCalledWith(
HOST_ID,
identity.agent,
expect.objectContaining({ signal: controller.signal })
identity.agent.installationId,
'adopt'
)
expect(value.activateRuntime).toHaveBeenCalledWith(
HOST_ID,
identity.runtime,
expect.objectContaining({
agentInstallationId: identity.agent.installationId,
signal: controller.signal
})
identity.agent.installationId,
{
kind: 'runtime-activate',
runtimeId: identity.runtime.runtimeId,
bundleDigest: identity.runtime.bundleDigest,
architecture: identity.runtime.architecture,
forceVerification: true
}
)
expect(value.cleanup).toHaveBeenCalledWith(
remoteCandidate.operationId,
{ signal: controller.signal }
)
expect(value.operationStore.save).toHaveBeenCalledOnce()
expect(value.operationStore.save).toHaveBeenCalledTimes(2)
expect(value.operationStore.remove).toHaveBeenCalledOnce()
expect(progress.some((event) =>
event.method === 'remote-download' &&
@@ -495,6 +515,27 @@ describe('RemoteEnvironmentPreparer', () => {
expect(value.archiveRelease).toHaveBeenCalledOnce()
})
it('keeps an adopted environment healthy when staging cleanup must be retried', async () => {
const value = fixture({
cleanup: vi.fn(async (operationId: string) => ({
cleaned: false,
operationId,
reason: 'cleanup-failed'
}))
})
await expect(value.preparer.prepare(
HOST_ID,
'remote-download',
undefined,
new AbortController().signal
)).resolves.toBeUndefined()
expect(value.activateAgent).toHaveBeenCalledOnce()
expect(value.activateRuntime).toHaveBeenCalledOnce()
expect(value.stopCandidate).not.toHaveBeenCalled()
expect(value.operationStore.remove).not.toHaveBeenCalled()
})
it('releases the archive and cleans the operation when a GoodBuddy upload fails', async () => {
const uploadFailure = new Error('upload failed')
const value = fixture({
@@ -684,12 +725,39 @@ describe('RemoteEnvironmentPreparer', () => {
new AbortController().signal
)).rejects.toBeInstanceOf(SshRemotePackageCommitIndeterminateError)
expect(value.cleanup).not.toHaveBeenCalled()
expect(value.operationStore.save).toHaveBeenCalledOnce()
expect(value.operationStore.save).toHaveBeenCalledTimes(2)
expect(value.operationStore.remove).not.toHaveBeenCalled()
expect(value.activateAgent).not.toHaveBeenCalled()
expect(value.activateRuntime).not.toHaveBeenCalled()
})
it('rolls metadata back when cancellation arrives with a successful commit result', async () => {
const controller = new AbortController()
const cancellation =
new DOMException('cancelled after commit', 'AbortError')
const value = fixture({
commit: vi.fn(async (input: { operationId: string }) => {
controller.abort(cancellation)
return {
committed: true,
operationId: input.operationId,
identity
}
})
})
await expect(value.preparer.prepare(
HOST_ID,
'remote-download',
undefined,
controller.signal
)).rejects.toBe(cancellation)
expect(value.stopCandidate).toHaveBeenCalledOnce()
expect(value.cleanup).toHaveBeenCalledOnce()
expect(value.operationStore.remove).toHaveBeenCalledOnce()
expect(value.activateAgent).not.toHaveBeenCalled()
})
it('keeps rollback-incomplete commits recoverable without cleanup', async () => {
const value = fixture({
commit: vi.fn(async () => ({
@@ -710,7 +778,7 @@ describe('RemoteEnvironmentPreparer', () => {
expect(value.operationStore.remove).not.toHaveBeenCalled()
})
it('reconciles and cleans a pending commit before starting new work', async () => {
it('resumes adoption for a committed operation instead of reinstalling it', async () => {
const pendingOperationId =
'00000000-0000-4000-8000-000000000777'
const originalTarget = target()
@@ -724,14 +792,21 @@ describe('RemoteEnvironmentPreparer', () => {
const value = fixture({
targets: [renamedTarget],
pendingOperation: {
version: 1,
version: 2,
hostId: HOST_ID,
targetIdentity:
remoteHostRecoveryIdentityKey(originalTarget),
operationId: pendingOperationId,
size: candidate.size,
sha256: candidate.sha256,
urls: candidate.urls
urls: candidate.urls,
metadataSnapshots:
REMOTE_ENVIRONMENT_METADATA_PATHS.map(
(path) => ({
path,
uid: 1000
})
)
}
})
@@ -751,10 +826,158 @@ describe('RemoteEnvironmentPreparer', () => {
expect(value.cleanup.mock.calls[0]?.[0]).toBe(
pendingOperationId
)
expect(value.activateAgent).toHaveBeenCalledOnce()
expect(value.activateRuntime).toHaveBeenCalledOnce()
expect(value.prepare).not.toHaveBeenCalled()
expect(value.operationStore.remove).toHaveBeenCalledOnce()
})
it('cleans a legacy pending operation and starts a fresh authenticated prepare', async () => {
const originalTarget = target()
const pendingOperationId =
'00000000-0000-4000-8000-000000000780'
const value = fixture({
targets: [originalTarget],
pendingOperation: {
version: 1,
hostId: HOST_ID,
targetIdentity:
remoteHostRecoveryIdentityKey(originalTarget),
operationId: pendingOperationId,
size: candidate.size,
sha256: candidate.sha256,
urls: candidate.urls
}
})
await value.preparer.prepare(
HOST_ID,
'remote-download',
undefined,
new AbortController().signal
)
expect(value.commitStatus).not.toHaveBeenCalled()
expect(value.cleanup.mock.calls[0]?.[0]).toBe(
pendingOperationId
)
expect(value.prepare).toHaveBeenCalledOnce()
expect(value.operationStore.remove).toHaveBeenCalledTimes(2)
})
it('restores the durable snapshot when resumed commit does not complete', async () => {
const originalTarget = target()
const originals = new Map<string, Buffer>(
REMOTE_ENVIRONMENT_METADATA_PATHS.map(
(path, index) => [
path,
Buffer.from(`before-commit-${index}`)
]
)
)
const files = new Map<string, Buffer>(
REMOTE_ENVIRONMENT_METADATA_PATHS.map(
(path) => [path, Buffer.from('partial-commit')]
)
)
const value = fixture({
targets: [originalTarget],
metadataFiles: files,
pendingOperation: {
version: 2,
hostId: HOST_ID,
targetIdentity:
remoteHostRecoveryIdentityKey(originalTarget),
operationId:
'00000000-0000-4000-8000-000000000781',
size: candidate.size,
sha256: candidate.sha256,
urls: candidate.urls,
metadataSnapshots: [...originals].map(
([path, contents]) => ({
path: path as never,
uid: 1000,
contentsBase64: contents.toString('base64')
})
)
},
commitStatus: vi.fn(async () => ({
committed: false,
reason: 'installer-failed'
}))
})
await value.preparer.prepare(
HOST_ID,
'remote-download',
undefined,
new AbortController().signal
)
expect([...files]).toEqual([...originals])
expect(value.prepare).toHaveBeenCalledOnce()
})
it('restores persisted pre-commit metadata when resumed Runtime adoption fails', async () => {
const originalTarget = target()
const originals = new Map<string, Buffer>(
REMOTE_ENVIRONMENT_METADATA_PATHS.map(
(path, index) => [
path,
Buffer.from(`original-${index}`)
]
)
)
const files = new Map<string, Buffer>(
REMOTE_ENVIRONMENT_METADATA_PATHS.map(
(path) => [path, Buffer.from('published')]
)
)
const runtimeFailure = new Error('runtime recovery failed')
const value = fixture({
targets: [originalTarget],
metadataFiles: files,
pendingOperation: {
version: 2,
hostId: HOST_ID,
targetIdentity:
remoteHostRecoveryIdentityKey(originalTarget),
operationId:
'00000000-0000-4000-8000-000000000779',
size: candidate.size,
sha256: candidate.sha256,
urls: candidate.urls,
metadataSnapshots: [...originals].map(
([path, contents]) => ({
path: path as never,
uid: 1000,
contentsBase64: contents.toString('base64')
})
)
},
activateAgent: vi.fn(async () => {
for (const path of REMOTE_ENVIRONMENT_METADATA_PATHS) {
files.set(path, Buffer.from('adopting'))
}
return { exitCode: 0, stdout: '{}', stderr: '' }
}),
activateRuntime: vi.fn(async () => {
throw runtimeFailure
})
})
await expect(value.preparer.prepare(
HOST_ID,
'remote-download',
undefined,
new AbortController().signal
)).rejects.toThrow('runtime recovery failed')
expect([...files]).toEqual([...originals])
expect(value.stopCandidate).toHaveBeenCalledOnce()
expect(value.cleanup).not.toHaveBeenCalled()
expect(value.operationStore.remove).not.toHaveBeenCalled()
})
it('retains pending recovery evidence when the real SSH target changes', async () => {
const originalTarget = target()
const changedTarget = target({
@@ -802,12 +1025,12 @@ describe('RemoteEnvironmentPreparer', () => {
const value = fixture({
metadataFiles: files,
activateAgent: vi.fn(async () => {
value.calls.push('agent')
value.metadataFiles.clear()
for (const path of REMOTE_ENVIRONMENT_METADATA_PATHS) {
value.metadataFiles.set(path, Buffer.from('new'))
}
return identity.agent
value.calls.push('agent')
return { exitCode: 0, stdout: '{}', stderr: '' }
}),
activateRuntime: vi.fn(async () => {
throw runtimeFailure
@@ -838,7 +1061,7 @@ describe('RemoteEnvironmentPreparer', () => {
metadataReplaceFailure: rollbackFailure,
activateAgent: vi.fn(async () => {
files.set(path, Buffer.from('changed'))
return identity.agent
return { exitCode: 0, stdout: '{}', stderr: '' }
}),
activateRuntime: vi.fn(async () => {
throw runtimeFailure