fix(runtime): backfill SSH states for targets learned without a state read

A failed-connect resync labelled a target with no ssh.getState, and the new
metadata-refresh gates then skipped it forever: no SSH chip and every worktree
mutation to that host failed the connection-generation check.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil
2026-08-10 23:21:51 -07:00
co-authored by Orca
parent 37fe018702
commit 088c99bedf
2 changed files with 48 additions and 11 deletions
@@ -190,6 +190,23 @@ describe('hydrateRuntimeEnvironmentSshState', () => {
])
})
it('reads state for a labelled target the bucket never recorded a state for', async () => {
const envId = nextEnvId()
// A failed-connect resync can label a target without ever reading its state.
useAppStore
.getState()
.setEnvironmentSshTargetsMetadata(envId, [{ id: 'ssh-new', label: 'new box' }])
installRpcResponses({
targets: [{ id: 'ssh-new', label: 'new box' }],
states: { 'ssh-new': connState('ssh-new') }
})
await refreshRuntimeEnvironmentSshTargetMetadata(envId)
const bucket = useAppStore.getState().sshStateByEnvironment.get(envId)
expect(bucket?.connectionStates.get('ssh-new')?.status).toBe('connected')
})
it('prunes removed targets and fetches state only for newly discovered targets', async () => {
const envId = nextEnvId()
useAppStore.getState().setEnvironmentSshTargetsMetadata(envId, [
@@ -558,4 +575,22 @@ describe('resyncRuntimeEnvironmentSshTargets', () => {
expect(bucket?.targetLabels.get('ssh-live')).toBe('devbox')
expect(bucket?.targetsHydrated).toBe(true)
})
it('reads the connection state of a host re-added under a new target id', async () => {
const envId = nextEnvId()
useAppStore
.getState()
.setEnvironmentSshTargetsMetadata(envId, [{ id: 'ssh-old', label: 'devbox' }])
installRpcResponses({
targets: [{ id: 'ssh-new', label: 'devbox' }],
labels: { 'ssh-old': 'devbox' },
states: { 'ssh-new': connState('ssh-new') }
})
await resyncRuntimeEnvironmentSshTargets(envId)
const bucket = useAppStore.getState().sshStateByEnvironment.get(envId)
expect(bucket?.targetLabels.get('ssh-new')).toBe('devbox')
expect(bucket?.connectionStates.get('ssh-new')?.status).toBe('connected')
})
})
@@ -130,16 +130,19 @@ async function runEnvironmentSshTargetMetadataRefresh(environmentId: string): Pr
const metadataChanged =
!bucket?.targetsHydrated || !sshTargetLabelsEqual(bucket.targetLabels, targets)
useAppStore.getState().setEnvironmentSshTargetsMetadata(environmentId, targets, generation)
const priorTargetIds = new Set(bucket?.targetLabels.keys() ?? [])
// Why: read states after the write — a resync racing this fetch can label a target that never had one read.
const knownStates = useAppStore.getState().sshStateByEnvironment.get(environmentId)
?.connectionStates
const needStateRead = targets.filter(
(target) => !priorTargetIds.has(target.id) || !knownStates?.has(target.id)
)
if (!metadataChanged) {
await fetchEnvironmentSshConnectionStates(environmentId, needStateRead, generation)
return
}
await syncEnvironmentRemovedSshTargetLabels(environmentId, generation)
const priorTargetIds = new Set(bucket?.targetLabels.keys() ?? [])
await fetchEnvironmentSshConnectionStates(
environmentId,
targets.filter((target) => !priorTargetIds.has(target.id)),
generation
)
await fetchEnvironmentSshConnectionStates(environmentId, needStateRead, generation)
}
function requestSshRefreshRerun(entry: SshRefreshEntry, kind: SshRefreshKind): void {
@@ -287,10 +290,9 @@ export async function connectRuntimeEnvironmentSshTarget(
}
/** Resyncs the environment's target metadata after a failed connect so a
* stale overlay converges to the ghost/re-adopted state (STA-1468). */
* stale overlay converges to the ghost/re-adopted state (STA-1468). Full
* hydration, not metadata alone: a host re-added under a new target id must
* land with a connection state or its chip and mutation gate stay dead. */
export async function resyncRuntimeEnvironmentSshTargets(environmentId: string): Promise<void> {
await syncEnvironmentSshTargetMetadata(
environmentId,
getEnvironmentSshStateGeneration(environmentId)
)
await hydrateRuntimeEnvironmentSshState(environmentId, { force: true })
}