Files
goodbuddy/src/main/agent/runtime-session-binding-store.test.ts
T
mesalogo 561de29f00 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 任务在桌面端、网络或电源中断后可继续在主机上运行,并能回到原会话精确恢复;项目菜单现按主机分组并显示实时连接状态。
2026-08-31 11:04:41 +08:00

767 lines
24 KiB
TypeScript

import { DatabaseSync } from 'node:sqlite'
import { mkdtemp, rm, stat } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { afterEach, describe, expect, it } from 'vitest'
import type { RuntimeSessionBinding } from '../../shared/remote-agent-contracts'
import {
MemoryRuntimeSessionBindingStore,
RuntimeSessionBindingCorruptionError,
RuntimeSessionBindingConflictError,
RuntimeSessionClaimConflictError,
SqliteRuntimeSessionBindingStore
} from './runtime-session-binding-store'
const digest = `sha256:${'a'.repeat(64)}`
function binding(
bindingId: string,
conversationId: string
): RuntimeSessionBinding {
return {
bindingId,
controllerId: 'controller-1',
controllerGeneration: 1,
conversationId,
hostId: 'host-1',
hostRevision: 1,
hostKeyGeneration: 1,
workspaceIdentity: 'workspace-1',
agentInstallationId: 'installation-1',
daemonBootIdAtOpen: 'boot-1',
runtimeId: 'runtime-1',
runtimeBundleDigest: digest,
runtimeAdapterDigest: digest,
acpSessionId: `opening-${bindingId}`,
acpCapabilitiesDigest: digest,
state: 'opening',
promptSequence: 0,
channelEpoch: '1',
lastOutboundJournaledSequence: '0',
lastOutboundDeliveredSequence: '0',
lastInboundJournaledSequence: '0',
lastMainAckSequence: '0'
}
}
describe('MemoryRuntimeSessionBindingStore', () => {
it('atomically rejects concurrent claims for one live ACP session', async () => {
const store = new MemoryRuntimeSessionBindingStore()
await store.put(binding('binding-1', 'conversation-1'))
await store.put(binding('binding-2', 'conversation-2'))
const claims = await Promise.allSettled([
store.claimAcpSession('binding-1', 'shared-session'),
store.claimAcpSession('binding-2', 'shared-session')
])
expect(claims.filter((claim) => claim.status === 'fulfilled')).toHaveLength(1)
const rejected = claims.find(
(claim): claim is PromiseRejectedResult =>
claim.status === 'rejected'
)
expect(rejected?.reason).toBeInstanceOf(
RuntimeSessionClaimConflictError
)
})
it('allows a closed owner session identity to be claimed again', async () => {
const store = new MemoryRuntimeSessionBindingStore()
const first = binding('binding-1', 'conversation-1')
await store.put(first)
const claimed = await store.claimAcpSession(
first.bindingId,
'session-1'
)
await store.put({ ...claimed, state: 'closed' })
const second = binding('binding-2', 'conversation-2')
await store.put(second)
await expect(
store.claimAcpSession(second.bindingId, 'session-1')
).resolves.toMatchObject({
bindingId: 'binding-2',
acpSessionId: 'session-1'
})
})
it('keeps binding and channel ownership immutable', async () => {
const store = new MemoryRuntimeSessionBindingStore()
const original = binding('binding-1', 'conversation-1')
await store.put(original)
await expect(
store.put({ ...original, channelEpoch: '2' })
).rejects.toBeInstanceOf(RuntimeSessionBindingConflictError)
await expect(
store.put({
...original,
controllerGeneration: 2
})
).rejects.toBeInstanceOf(RuntimeSessionBindingConflictError)
await expect(
store.put({ ...original, hostRevision: 2 })
).rejects.toBeInstanceOf(RuntimeSessionBindingConflictError)
await expect(
store.put(
binding('binding-2', original.conversationId)
)
).rejects.toBeInstanceOf(RuntimeSessionBindingConflictError)
})
it('keeps the persisted model bridge version and policy immutable', async () => {
const store = new MemoryRuntimeSessionBindingStore()
const original = {
...binding('binding-model', 'conversation-model'),
modelBridgeVersion:
'goodbuddy-model-bridge-v1' as const,
modelBridgePolicy: {
protocol: 'openai-responses' as const,
model: 'gpt-test',
modelProfileDigest: digest,
supportsImageInput: false
}
}
await store.put(original)
await expect(
store.put({
...original,
modelBridgePolicy: {
...original.modelBridgePolicy,
model: 'different-model'
}
})
).rejects.toThrow('model bridge policy is immutable')
await expect(
store.put({
...original,
modelBridgeVersion: undefined,
modelBridgePolicy: undefined
})
).rejects.toThrow('modelBridgeVersion is immutable')
})
it('atomically rotates an idle or actively recovering transport and resets its cursors', async () => {
const store = new MemoryRuntimeSessionBindingStore()
const ready = {
...binding('binding-1', 'conversation-1'),
state: 'ready' as const,
acpSessionId: 'session-1',
lastOutboundJournaledSequence: '4',
lastOutboundDeliveredSequence: '3',
lastInboundJournaledSequence: '6',
lastMainAckSequence: '5'
}
await store.put(ready)
await expect(
store.rotateTransport(
ready.bindingId,
{
controllerGeneration: 1,
daemonBootIdAtOpen: 'boot-1',
channelEpoch: '1'
},
{
controllerGeneration: 2,
daemonBootIdAtOpen: 'boot-2',
channelEpoch: '2'
}
)
).resolves.toMatchObject({
acpSessionId: 'session-1',
controllerGeneration: 2,
daemonBootIdAtOpen: 'boot-2',
channelEpoch: '2',
lastOutboundJournaledSequence: '0',
lastOutboundDeliveredSequence: '0',
lastInboundJournaledSequence: '0',
lastMainAckSequence: '0'
})
const running = {
...binding('binding-2', 'conversation-2'),
state: 'prompt-running' as const,
activePromptOperationId: 'operation-2',
acpSessionId: 'session-2'
}
await store.put(running)
await expect(
store.rotateTransport(
running.bindingId,
{
controllerGeneration: 1,
daemonBootIdAtOpen: 'boot-1',
channelEpoch: '1'
},
{
controllerGeneration: 2,
daemonBootIdAtOpen: 'boot-2',
channelEpoch: '2'
}
)
).resolves.toMatchObject({
state: 'prompt-running',
activePromptOperationId: 'operation-2',
acpSessionId: 'session-2',
controllerGeneration: 2,
channelEpoch: '2'
})
await expect(
store.rotateTransport(
ready.bindingId,
{
controllerGeneration: 1,
daemonBootIdAtOpen: 'boot-1',
channelEpoch: '1'
},
{
controllerGeneration: 3,
daemonBootIdAtOpen: 'boot-3',
channelEpoch: '3'
}
)
).rejects.toBeInstanceOf(RuntimeSessionBindingConflictError)
})
})
describe('SqliteRuntimeSessionBindingStore', () => {
const temporaryDirectories: string[] = []
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) =>
rm(directory, { recursive: true, force: true })
)
)
})
async function databasePath(): Promise<string> {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-runtime-bindings-')
)
temporaryDirectories.push(directory)
return join(directory, 'bindings.sqlite')
}
it.runIf(process.platform !== 'win32')(
'creates the database as a private regular file',
async () => {
const path = await databasePath()
const store = new SqliteRuntimeSessionBindingStore(path)
const file = await stat(path)
expect(file.isFile()).toBe(true)
expect(file.mode & 0o777).toBe(0o600)
store.close()
}
)
it('rejects in-memory and file URI database paths', () => {
expect(
() => new SqliteRuntimeSessionBindingStore(':memory:')
).toThrow('filesystem path')
expect(
() =>
new SqliteRuntimeSessionBindingStore(
'file:runtime-bindings.sqlite'
)
).toThrow('filesystem path')
})
it('persists bindings and cursors across reopen', async () => {
const path = await databasePath()
const first = new SqliteRuntimeSessionBindingStore(path)
const persisted = {
...binding('binding-1', 'conversation-1'),
state: 'ready' as const,
modelBridgeVersion:
'goodbuddy-model-bridge-v1' as const,
modelBridgePolicy: {
protocol: 'openai-responses' as const,
model: 'gpt-test',
modelProfileDigest: digest,
supportsImageInput: false
},
lastOutboundJournaledSequence: '4',
lastOutboundDeliveredSequence: '3',
lastInboundJournaledSequence: '6',
lastMainAckSequence: '5'
}
await first.put(persisted)
first.close()
const reopened = new SqliteRuntimeSessionBindingStore(path)
await expect(reopened.getById(persisted.bindingId)).resolves.toEqual(
persisted
)
const returned = await reopened.getById(persisted.bindingId)
if (!returned) {
throw new Error('Expected persisted binding')
}
returned.lastMainAckSequence = '0'
await expect(reopened.getById(persisted.bindingId)).resolves.toEqual(
persisted
)
reopened.dispose()
})
it('persists an atomic ready transport rotation', async () => {
const path = await databasePath()
const store = new SqliteRuntimeSessionBindingStore(path)
const ready = {
...binding('binding-1', 'conversation-1'),
state: 'ready' as const,
acpSessionId: 'session-1',
lastOutboundJournaledSequence: '4',
lastOutboundDeliveredSequence: '3',
lastInboundJournaledSequence: '6',
lastMainAckSequence: '5'
}
await store.put(ready)
await store.rotateTransport(
ready.bindingId,
{
controllerGeneration: 1,
daemonBootIdAtOpen: 'boot-1',
channelEpoch: '1'
},
{
controllerGeneration: 2,
daemonBootIdAtOpen: 'boot-2',
channelEpoch: '2'
}
)
store.close()
const reopened = new SqliteRuntimeSessionBindingStore(path)
await expect(reopened.getById(ready.bindingId)).resolves.toMatchObject({
acpSessionId: 'session-1',
state: 'ready',
controllerGeneration: 2,
daemonBootIdAtOpen: 'boot-2',
channelEpoch: '2',
lastOutboundJournaledSequence: '0',
lastOutboundDeliveredSequence: '0',
lastInboundJournaledSequence: '0',
lastMainAckSequence: '0'
})
reopened.close()
})
it('rejects live conversation conflicts and permits idempotent writes', async () => {
const store = new SqliteRuntimeSessionBindingStore(await databasePath())
const original = binding('binding-1', 'conversation-1')
await store.put(original)
await expect(store.put(original)).resolves.toBeUndefined()
await expect(
store.put(binding('binding-2', original.conversationId))
).rejects.toBeInstanceOf(RuntimeSessionBindingConflictError)
store.close()
})
it('enforces a lower row cap when reopening by pruning only closed rows', async () => {
const path = await databasePath()
const original = new SqliteRuntimeSessionBindingStore(path, {
maximumRows: 3
})
await original.put(binding('binding-live-1', 'conversation-live-1'))
await original.put(binding('binding-live-2', 'conversation-live-2'))
await original.put({
...binding('binding-closed', 'conversation-closed'),
state: 'closed'
})
original.close()
const capped = new SqliteRuntimeSessionBindingStore(path, {
maximumRows: 2
})
await expect(
capped.getById('binding-closed')
).resolves.toBeUndefined()
await expect(
capped.getById('binding-live-1')
).resolves.toBeDefined()
await expect(
capped.getById('binding-live-2')
).resolves.toBeDefined()
capped.close()
expect(
() =>
new SqliteRuntimeSessionBindingStore(path, {
maximumRows: 1
})
).toThrow(RuntimeSessionBindingConflictError)
})
it('atomically resolves competing claims from separate connections', async () => {
const path = await databasePath()
const first = new SqliteRuntimeSessionBindingStore(path)
const second = new SqliteRuntimeSessionBindingStore(path)
await first.put(binding('binding-1', 'conversation-1'))
await second.put(binding('binding-2', 'conversation-2'))
const claims = await Promise.allSettled([
first.claimAcpSession('binding-1', 'shared-session'),
second.claimAcpSession('binding-2', 'shared-session')
])
expect(claims.filter((claim) => claim.status === 'fulfilled')).toHaveLength(
1
)
const failure = claims.find(
(claim): claim is PromiseRejectedResult => claim.status === 'rejected'
)
expect(failure?.reason).toBeInstanceOf(RuntimeSessionClaimConflictError)
first.close()
second.close()
})
it('releases conversation and ACP ownership when a binding closes', async () => {
const store = new SqliteRuntimeSessionBindingStore(await databasePath())
const first = binding('binding-1', 'conversation-1')
await store.put(first)
const claimed = await store.claimAcpSession(first.bindingId, 'session-1')
await store.put({ ...claimed, state: 'closed' })
const replacement = {
...binding('binding-2', first.conversationId),
acpSessionId: 'session-1'
}
await expect(store.put(replacement)).resolves.toBeUndefined()
await expect(
store.getByConversation(first.conversationId)
).resolves.toMatchObject({ bindingId: replacement.bindingId })
store.close()
})
it('rejects immutable mutation and direct ACP identity mutation', async () => {
const store = new SqliteRuntimeSessionBindingStore(await databasePath())
const original = binding('binding-1', 'conversation-1')
await store.put(original)
await expect(
store.put({ ...original, channelEpoch: '2' })
).rejects.toBeInstanceOf(RuntimeSessionBindingConflictError)
await expect(
store.put({ ...original, workspaceIdentity: 'workspace-2' })
).rejects.toBeInstanceOf(RuntimeSessionBindingConflictError)
await expect(
store.put({ ...original, hostRevision: 2 })
).rejects.toBeInstanceOf(RuntimeSessionBindingConflictError)
await expect(
store.put({ ...original, acpSessionId: 'other-session' })
).rejects.toBeInstanceOf(RuntimeSessionBindingConflictError)
store.close()
})
it('rejects reopening a closed binding and preserves the closed row', async () => {
const store = new SqliteRuntimeSessionBindingStore(await databasePath())
const original = binding('binding-1', 'conversation-1')
await store.put(original)
const closed = { ...original, state: 'closed' as const }
await store.put(closed)
await expect(
store.put({ ...closed, state: 'ready' })
).rejects.toBeInstanceOf(RuntimeSessionBindingConflictError)
await expect(store.getById(original.bindingId)).resolves.toEqual(closed)
store.close()
})
it('rejects interrupted bindings becoming ready', async () => {
const store = new SqliteRuntimeSessionBindingStore(await databasePath())
const original = binding('binding-1', 'conversation-1')
const interrupted = { ...original, state: 'interrupted' as const }
await store.put(original)
await store.put(interrupted)
await expect(
store.put({ ...interrupted, state: 'ready' })
).rejects.toBeInstanceOf(RuntimeSessionBindingConflictError)
await expect(store.getById(original.bindingId)).resolves.toEqual(
interrupted
)
store.close()
})
it('fails closed when persisted JSON is malformed', async () => {
const path = await databasePath()
const store = new SqliteRuntimeSessionBindingStore(path)
const original = binding('binding-1', 'conversation-1')
await store.put(original)
store.close()
const raw = new DatabaseSync(path)
raw
.prepare(`
UPDATE runtime_session_bindings
SET binding_json = ?
WHERE binding_id = ?
`)
.run('{}', original.bindingId)
raw.close()
const reopened = new SqliteRuntimeSessionBindingStore(path)
await expect(reopened.put(original)).rejects.toBeInstanceOf(
RuntimeSessionBindingCorruptionError
)
await expect(reopened.getById(original.bindingId)).rejects.toBeInstanceOf(
RuntimeSessionBindingCorruptionError
)
await expect(
reopened.listByController(original.controllerId)
).rejects.toBeInstanceOf(RuntimeSessionBindingCorruptionError)
reopened.close()
})
it('fails closed when indexed identity disagrees with valid JSON', async () => {
const path = await databasePath()
const store = new SqliteRuntimeSessionBindingStore(path)
const original = binding('binding-1', 'conversation-1')
await store.put(original)
store.close()
const raw = new DatabaseSync(path)
raw
.prepare(`
UPDATE runtime_session_bindings
SET controller_id = ?
WHERE binding_id = ?
`)
.run('controller-corrupt', original.bindingId)
raw.close()
const reopened = new SqliteRuntimeSessionBindingStore(path)
await expect(reopened.getById(original.bindingId)).rejects.toBeInstanceOf(
RuntimeSessionBindingCorruptionError
)
reopened.close()
})
})
describe('RuntimeSessionBindingStore lifecycle parity', () => {
const temporaryDirectories: string[] = []
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) =>
rm(directory, { recursive: true, force: true })
)
)
})
it('enforces the same legal and terminal transitions in both stores', async () => {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-runtime-bindings-parity-')
)
temporaryDirectories.push(directory)
const sqliteStore = new SqliteRuntimeSessionBindingStore(
join(directory, 'bindings.sqlite')
)
const stores: Array<{
store:
| MemoryRuntimeSessionBindingStore
| SqliteRuntimeSessionBindingStore
close(): void
}> = [
{
store: new MemoryRuntimeSessionBindingStore(),
close() {}
},
{
store: sqliteStore,
close() {
sqliteStore.close()
}
}
]
for (const entry of stores) {
const original = binding('binding-1', 'conversation-1')
const running = {
...original,
state: 'prompt-running' as const,
activePromptOperationId: 'operation-1'
}
await entry.store.put(original)
await entry.store.put(running)
const advancedRunning = {
...running,
lastOutboundJournaledSequence: '1'
}
await entry.store.put(advancedRunning)
await expect(
entry.store.put(running)
).rejects.toBeInstanceOf(RuntimeSessionBindingConflictError)
await expect(
entry.store.put({
...advancedRunning,
activePromptOperationId: 'operation-2'
})
).rejects.toBeInstanceOf(RuntimeSessionBindingConflictError)
const ready = {
...advancedRunning,
state: 'ready' as const,
activePromptOperationId: undefined
}
await entry.store.put(ready)
await entry.store.put({
...ready,
state: 'interrupted'
})
await expect(
entry.store.put(ready)
).rejects.toBeInstanceOf(RuntimeSessionBindingConflictError)
const interrupted = {
...ready,
state: 'interrupted' as const,
lastOutboundJournaledSequence: '2'
}
await entry.store.put(interrupted)
await entry.store.put({
...interrupted,
state: 'closed'
})
await expect(
entry.store.put(ready)
).rejects.toBeInstanceOf(RuntimeSessionBindingConflictError)
const directReady = binding('binding-2', 'conversation-2')
await entry.store.put(directReady)
await entry.store.put({
...directReady,
state: 'ready'
})
entry.close()
}
})
it('caps rows by pruning only closed bindings in both stores', async () => {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-runtime-bindings-cap-')
)
temporaryDirectories.push(directory)
const sqliteStore = new SqliteRuntimeSessionBindingStore(
join(directory, 'bindings.sqlite'),
{ maximumRows: 3 }
)
const stores = [
{
store: new MemoryRuntimeSessionBindingStore({ maximumRows: 3 }),
close() {}
},
{
store: sqliteStore,
close() {
sqliteStore.close()
}
}
]
for (const entry of stores) {
const authority = {
...binding('binding-live', 'conversation-live'),
state: 'interrupted' as const
}
const closed = {
...binding('binding-closed', 'conversation-closed'),
state: 'closed' as const
}
const unknown = {
...binding('binding-unknown', 'conversation-unknown'),
state: 'outcome-unknown' as const,
activePromptOperationId: 'operation-unknown'
}
await entry.store.put(authority)
await entry.store.put(unknown)
await entry.store.put(closed)
await entry.store.put(binding('binding-new', 'conversation-new'))
await expect(
entry.store.getById(closed.bindingId)
).resolves.toBeUndefined()
await expect(
entry.store.getById(authority.bindingId)
).resolves.toEqual(authority)
await expect(
entry.store.getById(unknown.bindingId)
).resolves.toEqual(unknown)
await expect(
entry.store.put(binding('binding-over', 'conversation-over'))
).rejects.toBeInstanceOf(RuntimeSessionBindingConflictError)
await expect(
entry.store.getById(authority.bindingId)
).resolves.toEqual(authority)
entry.close()
}
})
it('prunes closed rows deterministically and paginates both stores', async () => {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-runtime-bindings-page-')
)
temporaryDirectories.push(directory)
const sqliteStore = new SqliteRuntimeSessionBindingStore(
join(directory, 'bindings.sqlite'),
{ maximumRows: 10 }
)
const stores = [
{
store: new MemoryRuntimeSessionBindingStore({ maximumRows: 10 }),
close() {}
},
{
store: sqliteStore,
close() {
sqliteStore.close()
}
}
]
for (const entry of stores) {
for (const id of ['binding-3', 'binding-1', 'binding-2']) {
await entry.store.put(binding(id, `conversation-${id}`))
}
const firstPage = await entry.store.listByController('controller-1', {
limit: 2
})
expect(firstPage.map((item) => item.bindingId)).toEqual([
'binding-1',
'binding-2'
])
await expect(
entry.store.listByController('controller-1', {
limit: 2,
afterBindingId: 'binding-2'
})
).resolves.toMatchObject([{ bindingId: 'binding-3' }])
await expect(
entry.store.listByController('controller-1', { limit: 1_001 })
).rejects.toBeInstanceOf(RangeError)
await entry.store.put({
...(await entry.store.getById('binding-1'))!,
state: 'closed'
})
await entry.store.put({
...(await entry.store.getById('binding-2'))!,
state: 'closed'
})
await expect(entry.store.pruneClosed(1)).resolves.toBe(1)
await expect(entry.store.pruneClosed(1_001)).rejects.toBeInstanceOf(
RangeError
)
await expect(
entry.store.getById('binding-1')
).resolves.toBeUndefined()
await expect(
entry.store.getById('binding-2')
).resolves.toBeDefined()
await expect(
entry.store.getById('binding-3')
).resolves.toBeDefined()
entry.close()
}
})
})